feat: show cloud prices as lead indicator (#2347)

* feat: show cloud prices as lead indicator, default OpenClaw to Kimi K2.5

- Add `price` field to all clouds in manifest.json
- Show price as lead indicator in cloud picker hints, cloud listings, cloud info, and dry-run preview
- Change OpenClaw default model from openrouter/auto to moonshotai/kimi-k2.5 (top used model by OpenClaw users)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add defensive guards for undefined cloud price in cached manifests

When users upgrade CLI but have cached manifests from before the price
field was added, c.price is undefined. Add ?? "" fallbacks and an
if-guard to prevent runtime crashes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
This commit is contained in:
Ahmed Abushagur 2026-03-08 23:41:39 -07:00 committed by GitHub
parent c61852d689
commit 24a3c7328d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 61 additions and 11 deletions

View file

@ -57,7 +57,7 @@
"interactive_prompts": {
"model_id": {
"prompt": "Enter model ID",
"default": "openrouter/auto"
"default": "moonshotai/kimi-k2.5"
}
},
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/openclaw.png",
@ -249,6 +249,7 @@
"clouds": {
"local": {
"name": "Local Machine",
"price": "Free",
"description": "Your computer — no account or payment needed",
"url": "https://github.com/OpenRouterTeam/spawn",
"type": "local",
@ -260,7 +261,8 @@
},
"hetzner": {
"name": "Hetzner Cloud",
"description": "European cloud servers from ~€3/mo (account required)",
"price": "~€3/mo",
"description": "European cloud servers (account required)",
"url": "https://www.hetzner.com/cloud/",
"type": "api",
"auth": "HCLOUD_TOKEN",
@ -276,7 +278,8 @@
},
"aws": {
"name": "AWS Lightsail",
"description": "Amazon cloud servers from $3.50/mo (AWS account required)",
"price": "$3.50/mo",
"description": "Amazon cloud servers (AWS account required)",
"url": "https://aws.amazon.com/lightsail/",
"type": "cli",
"auth": "AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY",
@ -293,7 +296,8 @@
},
"digitalocean": {
"name": "DigitalOcean",
"description": "Cloud servers from $4/mo (account + payment method required)",
"price": "$4/mo",
"description": "Cloud servers (account + payment method required)",
"url": "https://www.digitalocean.com/",
"type": "api",
"auth": "DO_API_TOKEN",
@ -309,6 +313,7 @@
},
"gcp": {
"name": "GCP Compute Engine",
"price": "$7/mo",
"description": "Google cloud servers — $300 free trial (Google account required)",
"url": "https://cloud.google.com/compute",
"type": "cli",
@ -326,6 +331,7 @@
},
"sprite": {
"name": "Sprite",
"price": "Free tier",
"description": "Managed cloud servers — one command to deploy",
"url": "https://sprites.dev",
"type": "cli",

View file

@ -51,6 +51,7 @@ function createManifest(): Manifest {
sprite: {
name: "Sprite",
description: "Lightweight VMs",
price: "test",
url: "https://sprite.sh",
type: "vm",
auth: "SPRITE_TOKEN",
@ -61,6 +62,7 @@ function createManifest(): Manifest {
hetzner: {
name: "Hetzner Cloud",
description: "European cloud provider",
price: "test",
url: "https://hetzner.com",
type: "cloud",
auth: "HCLOUD_TOKEN",

View file

@ -59,6 +59,7 @@ function createTestManifest(): Manifest {
sprite: {
name: "Sprite",
description: "Lightweight VMs",
price: "test",
url: "https://sprite.sh",
type: "vm",
auth: "SPRITE_TOKEN",
@ -69,6 +70,7 @@ function createTestManifest(): Manifest {
hetzner: {
name: "Hetzner Cloud",
description: "European cloud provider",
price: "test",
url: "https://hetzner.com",
type: "cloud",
auth: "HCLOUD_TOKEN",
@ -79,6 +81,7 @@ function createTestManifest(): Manifest {
vultr: {
name: "Vultr",
description: "Cloud compute",
price: "test",
url: "https://vultr.com",
type: "cloud",
auth: "VULTR_API_KEY",
@ -377,6 +380,7 @@ describe("checkEntity", () => {
"local-cloud": {
name: "Local Cloud",
description: "Local cloud provider",
price: "test",
url: "",
type: "local",
auth: "none",

View file

@ -57,6 +57,7 @@ const smallManifest: Manifest = {
sprite: {
name: "Sprite",
description: "Lightweight VMs",
price: "test",
url: "https://sprite.sh",
type: "vm",
auth: "SPRITE_TOKEN",
@ -67,6 +68,7 @@ const smallManifest: Manifest = {
hetzner: {
name: "Hetzner Cloud",
description: "European cloud provider",
price: "test",
url: "https://hetzner.com",
type: "cloud",
auth: "HCLOUD_TOKEN",
@ -116,6 +118,7 @@ const multiTypeManifest: Manifest = {
local: {
name: "Local Machine",
description: "Run agents on your own machine",
price: "test",
url: "",
type: "local",
auth: "none",

View file

@ -25,6 +25,7 @@ const manifestWithNotes = {
emptycloud: {
name: "Empty Cloud",
description: "Cloud with no agents",
price: "test",
url: "https://empty.cloud",
type: "vm",
auth: "token",

View file

@ -40,6 +40,7 @@ const manyCloudManifest = {
vultr: {
name: "Vultr",
description: "Cloud compute",
price: "test",
url: "https://vultr.com",
type: "cloud",
auth: "token",
@ -50,6 +51,7 @@ const manyCloudManifest = {
linode: {
name: "Linode",
description: "Cloud hosting",
price: "test",
url: "https://linode.com",
type: "cloud",
auth: "token",
@ -60,6 +62,7 @@ const manyCloudManifest = {
digitalocean: {
name: "DigitalOcean",
description: "Cloud infrastructure",
price: "test",
url: "https://digitalocean.com",
type: "cloud",
auth: "token",

View file

@ -218,6 +218,7 @@ describe("getImplementedAgents", () => {
newcloud: {
name: "New Cloud",
description: "Test",
price: "test",
url: "",
type: "vm",
auth: "token",

View file

@ -60,6 +60,7 @@ const manifestWithDistinctNames = {
sp: {
name: "Sprite Cloud",
description: "Lightweight VMs",
price: "test",
url: "https://sprite.sh",
type: "vm",
auth: "token",
@ -70,6 +71,7 @@ const manifestWithDistinctNames = {
hz: {
name: "Hetzner Cloud",
description: "European cloud provider",
price: "test",
url: "https://hetzner.com",
type: "cloud",
auth: "token",
@ -80,6 +82,7 @@ const manifestWithDistinctNames = {
dc: {
name: "DigitalOcean",
description: "Cloud infrastructure",
price: "test",
url: "https://digitalocean.com",
type: "cloud",
auth: "token",

View file

@ -44,6 +44,7 @@ const manyCloudManifest = {
sprite: {
name: "Sprite",
description: "Lightweight VMs",
price: "test",
url: "https://sprite.sh",
type: "vm",
auth: "token",
@ -54,6 +55,7 @@ const manyCloudManifest = {
hetzner: {
name: "Hetzner Cloud",
description: "European cloud provider",
price: "test",
url: "https://hetzner.com",
type: "cloud",
auth: "token",
@ -64,6 +66,7 @@ const manyCloudManifest = {
vultr: {
name: "Vultr",
description: "Cloud compute",
price: "test",
url: "https://vultr.com",
type: "cloud",
auth: "token",
@ -74,6 +77,7 @@ const manyCloudManifest = {
linode: {
name: "Linode",
description: "Cloud hosting",
price: "test",
url: "https://linode.com",
type: "cloud",
auth: "token",
@ -84,6 +88,7 @@ const manyCloudManifest = {
digitalocean: {
name: "DigitalOcean",
description: "Cloud infrastructure",
price: "test",
url: "https://digitalocean.com",
type: "cloud",
auth: "token",

View file

@ -149,6 +149,11 @@ describe("Cloud required field types", () => {
expect(cloud.description.length).toBeGreaterThan(0);
});
it("price should be a non-empty string", () => {
expect(typeof cloud.price).toBe("string");
expect(cloud.price.length).toBeGreaterThan(0);
});
it("url should be a valid URL string", () => {
expect(typeof cloud.url).toBe("string");
expect(cloud.url).toMatch(/^https?:\/\//);

View file

@ -21,6 +21,7 @@ function makeManifest(cloudAuth: string): Manifest {
testcloud: {
name: "Test Cloud",
description: "A test cloud",
price: "test",
url: "https://test.cloud",
type: "vps",
auth: cloudAuth,

View file

@ -43,6 +43,7 @@ function makeManifest(overrides?: Partial<Manifest>): Manifest {
hetzner: {
name: "Hetzner Cloud",
description: "German cloud provider",
price: "test",
url: "https://hetzner.cloud",
type: "api",
auth: "HCLOUD_TOKEN",
@ -53,6 +54,7 @@ function makeManifest(overrides?: Partial<Manifest>): Manifest {
sprite: {
name: "Sprite",
description: "Instant cloud dev environments",
price: "test",
url: "https://sprite.dev",
type: "cli",
auth: "sprite login",
@ -63,6 +65,7 @@ function makeManifest(overrides?: Partial<Manifest>): Manifest {
digitalocean: {
name: "DigitalOcean",
description: "Simple cloud hosting",
price: "test",
url: "https://digitalocean.com",
type: "api",
auth: "DO_API_TOKEN",
@ -73,6 +76,7 @@ function makeManifest(overrides?: Partial<Manifest>): Manifest {
upcloud: {
name: "UpCloud",
description: "European cloud provider",
price: "test",
url: "https://upcloud.com",
type: "api",
auth: "UPCLOUD_USERNAME + UPCLOUD_PASSWORD",
@ -83,6 +87,7 @@ function makeManifest(overrides?: Partial<Manifest>): Manifest {
localcloud: {
name: "Local Machine",
description: "Run locally",
price: "test",
url: "",
type: "local",
auth: "none",
@ -165,7 +170,7 @@ describe("prioritizeCloudsByCredentials", () => {
expect(result.sortedClouds).toEqual(clouds);
expect(result.credCount).toBe(0);
expect(Object.keys(result.hintOverrides)).toHaveLength(0);
expect(Object.keys(result.hintOverrides).length).toBeGreaterThanOrEqual(clouds.length);
});
it("should move clouds with credentials to front", () => {
@ -211,8 +216,8 @@ describe("prioritizeCloudsByCredentials", () => {
const result = prioritizeCloudsByCredentials(clouds, manifest);
expect(result.hintOverrides["hetzner"]).toContain("credentials detected");
expect(result.hintOverrides["hetzner"]).toContain("German cloud provider");
expect(result.hintOverrides["digitalocean"]).toBeUndefined();
expect(result.hintOverrides["hetzner"]).toContain("test");
expect(result.hintOverrides["digitalocean"]).toBeDefined();
});
it("should handle multi-var auth (both vars must be set)", () => {

View file

@ -34,6 +34,7 @@ export const createMockManifest = (): Manifest => ({
sprite: {
name: "Sprite",
description: "Lightweight VMs",
price: "test",
url: "https://sprite.sh",
type: "vm",
auth: "token",
@ -44,6 +45,7 @@ export const createMockManifest = (): Manifest => ({
hetzner: {
name: "Hetzner Cloud",
description: "European cloud provider",
price: "test",
url: "https://hetzner.com",
type: "cloud",
auth: "token",

View file

@ -251,7 +251,7 @@ export async function cmdClouds(): Promise<void> {
}
const credIndicator = formatCredentialIndicatorLocal(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}`,
` ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${c.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.bold((c.price ?? "").padEnd(16))} ${pc.dim(`${countStr.padEnd(6)} ${c.description}`)}${credIndicator}`,
);
}
}
@ -372,6 +372,9 @@ export async function cmdCloudInfo(cloud: string, preloadedManifest?: Manifest):
const c = manifest.clouds[cloudKey];
printInfoHeader(c);
if (c.price) {
console.log(` ${pc.bold(c.price)}`);
}
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);

View file

@ -116,11 +116,13 @@ function buildAgentLines(agentInfo: {
function buildCloudLines(cloudInfo: {
name: string;
price: string;
description: string;
defaults?: Record<string, string>;
}): string[] {
const lines = [
` Name: ${cloudInfo.name}`,
` Price: ${cloudInfo.price}`,
` Description: ${cloudInfo.description}`,
];
if (cloudInfo.defaults) {

View file

@ -425,13 +425,16 @@ export function prioritizeCloudsByCredentials(
const hintOverrides: Record<string, string> = {};
for (const c of withCreds) {
hintOverrides[c] = `credentials detected -- ${manifest.clouds[c].description}`;
hintOverrides[c] = `${manifest.clouds[c].price ?? ""} — credentials detected`;
}
for (const c of featured) {
hintOverrides[c] = `recommended -- ${manifest.clouds[c].description}`;
hintOverrides[c] = `${manifest.clouds[c].price ?? ""} — recommended`;
}
for (const c of withCli) {
hintOverrides[c] = `CLI installed -- ${manifest.clouds[c].description}`;
hintOverrides[c] = `${manifest.clouds[c].price ?? ""} — CLI installed`;
}
for (const c of rest) {
hintOverrides[c] = `${manifest.clouds[c].price ?? ""}${manifest.clouds[c].description}`;
}
return {

View file

@ -46,6 +46,7 @@ export interface AgentDef {
export interface CloudDef {
name: string;
description: string;
price: string;
url: string;
type: string;
auth: string;