feat: add cloud provider icons and metadata support (#1503)

Download favicon/icons for all 8 cloud providers into assets/clouds/:
- local.png     — OpenRouter apple-touch-icon (6.4K)
- hetzner.png   — Hetzner 180x180 apple icon (1.9K)
- fly.png       — Fly.io apple-touch-icon (6.4K)
- aws.png       — AWS 144x144 touch icon (3.1K)
- daytona.png   — Daytona favicon from Framer CDN (1.2K)
- digitalocean.png — DigitalOcean apple-touch-icon (6.0K)
- gcp.png       — Google Cloud super_cloud icon (4.2K)
- sprite.png    — Sprites.dev apple-touch-icon (1.9K)

Add assets/clouds/.sources.json tracking canonical source URLs.
Add optional `icon` field to CloudDef interface.
Update manifest.json with raw.githubusercontent.com icon URLs.
Add icon URL type validation test for clouds.
Bump CLI version 0.5.13 → 0.5.14.

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-19 21:51:40 -08:00 committed by GitHub
parent 015446eee8
commit d8785708c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 231 additions and 44 deletions

View file

@ -1,23 +1,28 @@
# Update Agent Metadata
# Update Agent & Cloud Metadata
Refresh agent icons (favicons) and all metadata/stats in `manifest.json` by fetching live data from GitHub and agent websites.
Refresh icons and metadata/stats for agents and cloud providers in `manifest.json`.
## When to use
Run this when:
- An agent's logo changes (new branding, new org avatar)
- An agent or cloud provider's logo changes
- An icon URL breaks (404 or stale redirect)
- A new agent is added to the manifest without metadata
- GitHub star counts need refreshing (run periodically)
- Agent repo info changed (license, language, description)
- You want a full metadata audit across all agents
- A new agent or cloud is added without an icon
- GitHub star counts need refreshing
- Agent repo info changed (license, language)
- You want a full metadata audit
- You need to validate that all source URLs are still reachable
## Arguments
- `--agent <id>` — Update only the specified agent (e.g. `--agent openclaw`). Omit to update all.
- `--dry-run` — Print what would change without writing files.
- `--agent <id>` — Update only the specified agent (e.g. `--agent openclaw`).
- `--cloud <id>` — Update only the specified cloud (e.g. `--cloud hetzner`).
- `--agents-only` — Only process agents, skip clouds.
- `--clouds-only` — Only process clouds, skip agents.
- `--icons-only` — Only refresh icons, skip GitHub metadata.
- `--stats-only` — Only refresh GitHub stats, skip icon downloads.
- `--validate` — Check all source URLs are reachable without downloading. Exits with code 1 if any are broken.
- `--dry-run` — Print what would change without writing files.
## Procedure
@ -27,4 +32,13 @@ Run the update script, passing through any arguments:
bun run .claude/skills/update-metadata/update.ts [arguments]
```
Review the output, then commit the changed files (`manifest.json`, `assets/agents/.sources.json`, and any updated icon files in `assets/agents/`).
### Fixing broken sources
If `--validate` reports broken URLs (marked with `✗`), fix them by editing the relevant `.sources.json` file:
- **Agent sources**: `assets/agents/.sources.json`
- **Cloud sources**: `assets/clouds/.sources.json`
Each entry maps an id to `{ "url": "...", "ext": "png" }`. Update the `url` to a working icon source (apple-touch-icon, GitHub org avatar, or favicon), then re-run the script without `--validate` to download and update everything.
Review the output, then commit the changed files (`manifest.json`, `.sources.json` files, and any updated icon files).

View file

@ -20,6 +20,11 @@ interface AgentEntry {
[key: string]: unknown;
}
interface CloudEntry {
icon?: string;
[key: string]: unknown;
}
interface SourceEntry {
url: string;
ext: string;
@ -29,7 +34,8 @@ interface SourceEntry {
const ROOT = resolve(import.meta.dir, "../../..");
const MANIFEST_PATH = resolve(ROOT, "manifest.json");
const SOURCES_PATH = resolve(ROOT, "assets/agents/.sources.json");
const AGENT_SOURCES_PATH = resolve(ROOT, "assets/agents/.sources.json");
const CLOUD_SOURCES_PATH = resolve(ROOT, "assets/clouds/.sources.json");
// ── Parse args ──────────────────────────────────────────────────────
@ -37,18 +43,36 @@ const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const iconsOnly = args.includes("--icons-only");
const statsOnly = args.includes("--stats-only");
const cloudsOnly = args.includes("--clouds-only");
const agentsOnly = args.includes("--agents-only");
const validateOnly = args.includes("--validate");
const agentIdx = args.indexOf("--agent");
const onlyAgent = agentIdx !== -1 ? args[agentIdx + 1] : null;
const cloudIdx = args.indexOf("--cloud");
const onlyCloud = cloudIdx !== -1 ? args[cloudIdx + 1] : null;
let hasErrors = false;
// ── Load data ───────────────────────────────────────────────────────
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf-8"));
const agents: Record<string, AgentEntry> = manifest.agents;
const sources: Record<string, SourceEntry> = existsSync(SOURCES_PATH)
? JSON.parse(readFileSync(SOURCES_PATH, "utf-8"))
const clouds: Record<string, CloudEntry> = manifest.clouds;
const agentSources: Record<string, SourceEntry> = existsSync(
AGENT_SOURCES_PATH
)
? JSON.parse(readFileSync(AGENT_SOURCES_PATH, "utf-8"))
: {};
const cloudSources: Record<string, SourceEntry> = existsSync(
CLOUD_SOURCES_PATH
)
? JSON.parse(readFileSync(CLOUD_SOURCES_PATH, "utf-8"))
: {};
const agentIds = onlyAgent ? [onlyAgent] : Object.keys(agents);
const cloudIds = onlyCloud ? [onlyCloud] : Object.keys(clouds);
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const EXT_MAP: Record<string, string> = {
@ -59,7 +83,7 @@ const EXT_MAP: Record<string, string> = {
"image/vnd.microsoft.icon": "ico",
};
const METADATA_FIELDS = [
const AGENT_METADATA_FIELDS = [
"creator",
"repo",
"license",
@ -74,11 +98,64 @@ const METADATA_FIELDS = [
"tags",
];
// ── Icon refresh ────────────────────────────────────────────────────
// ── Source URL validation ────────────────────────────────────────────
async function refreshIcons() {
console.log("── Refreshing icons ──");
for (const id of agentIds) {
async function validateSources(
label: string,
ids: string[],
entries: Record<string, { icon?: string; [k: string]: unknown }>,
sources: Record<string, SourceEntry>,
assetDir: string
) {
console.log(`── Validating ${label} source URLs ──`);
for (const id of ids) {
const src = sources[id];
if (!src) {
if (entries[id]?.icon) {
console.log(`${id}: has icon in manifest but MISSING from ${assetDir}/.sources.json`);
hasErrors = true;
} else {
console.log(`${id}: no source entry (no icon configured)`);
}
continue;
}
try {
const res = await fetch(src.url, { method: "HEAD" });
if (!res.ok) {
console.log(
`${id}: BROKEN source URL (HTTP ${res.status}) → ${src.url}`
);
hasErrors = true;
} else {
const contentType =
res.headers.get("content-type")?.split(";")[0] ?? "";
const isImage = contentType.startsWith("image/");
if (!isImage) {
console.log(
`${id}: source URL returns ${contentType}, not an image → ${src.url}`
);
} else {
console.log(`${id}: OK (${contentType})`);
}
}
} catch (err) {
console.log(`${id}: UNREACHABLE → ${src.url} (${err})`);
hasErrors = true;
}
}
}
// ── Generic icon refresh ────────────────────────────────────────────
async function refreshIconsFor(
label: string,
ids: string[],
entries: Record<string, { icon?: string; [k: string]: unknown }>,
sources: Record<string, SourceEntry>,
assetDir: string
) {
console.log(`── Refreshing ${label} icons ──`);
for (const id of ids) {
const src = sources[id];
if (!src) {
console.log(`${id}: no entry in .sources.json, skipping icon`);
@ -93,8 +170,8 @@ async function refreshIcons() {
const contentType =
res.headers.get("content-type")?.split(";")[0] ?? "";
const ext = EXT_MAP[contentType] ?? src.ext;
const outPath = resolve(ROOT, `assets/agents/${id}.${ext}`);
const rawUrl = `https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/${id}.${ext}`;
const outPath = resolve(ROOT, `${assetDir}/${id}.${ext}`);
const rawUrl = `https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/${assetDir}/${id}.${ext}`;
if (dryRun) {
console.log(
@ -103,7 +180,7 @@ async function refreshIcons() {
} else {
const buf = Buffer.from(await res.arrayBuffer());
writeFileSync(outPath, buf);
agents[id].icon = rawUrl;
entries[id].icon = rawUrl;
sources[id].ext = ext;
console.log(
`${id}: icon refreshed (${buf.length} bytes, .${ext})`
@ -115,10 +192,10 @@ async function refreshIcons() {
}
}
// ── GitHub metadata refresh ─────────────────────────────────────────
// ── GitHub metadata refresh (agents only) ───────────────────────────
async function refreshStats() {
console.log("── Refreshing GitHub stats ──");
async function refreshAgentStats() {
console.log("── Refreshing agent GitHub stats ──");
for (const id of agentIds) {
const agent = agents[id];
if (!agent.repo) {
@ -177,11 +254,11 @@ async function refreshStats() {
// ── Metadata completeness check ─────────────────────────────────────
function validateMetadata() {
console.log("── Metadata completeness ──");
function validateAgentMetadata() {
console.log("── Agent metadata completeness ──");
for (const id of agentIds) {
const agent = agents[id];
const missing = METADATA_FIELDS.filter((f) => agent[f] == null);
const missing = AGENT_METADATA_FIELDS.filter((f) => agent[f] == null);
if (missing.length > 0) {
console.log(`${id}: missing ${missing.join(", ")}`);
} else {
@ -190,16 +267,83 @@ function validateMetadata() {
}
}
function validateCloudIcons() {
console.log("── Cloud icon completeness ──");
for (const id of cloudIds) {
const cloud = clouds[id];
if (!cloud.icon) {
console.log(`${id}: missing icon`);
} else {
console.log(`${id}: icon present`);
}
}
}
// ── Main ────────────────────────────────────────────────────────────
async function main() {
console.log(
`Updating metadata for ${agentIds.length} agent(s)${dryRun ? " [dry-run]" : ""}...\n`
);
const scope = cloudsOnly
? "clouds"
: agentsOnly
? "agents"
: "agents + clouds";
const mode = validateOnly
? "validate"
: dryRun
? "dry-run"
: "update";
console.log(`${mode === "validate" ? "Validating" : "Updating"} metadata for ${scope}${mode === "dry-run" ? " [dry-run]" : ""}...\n`);
if (!statsOnly) await refreshIcons();
if (!iconsOnly) await refreshStats();
validateMetadata();
if (validateOnly) {
// Validate-only: HEAD-check all source URLs, report broken ones
if (!cloudsOnly)
await validateSources("agent", agentIds, agents, agentSources, "assets/agents");
if (!agentsOnly)
await validateSources("cloud", cloudIds, clouds, cloudSources, "assets/clouds");
if (!cloudsOnly) validateAgentMetadata();
if (!agentsOnly) validateCloudIcons();
if (hasErrors) {
console.log(
"\n✗ Validation failed — fix broken source URLs in .sources.json files"
);
process.exit(1);
} else {
console.log("\n✓ All source URLs valid");
}
return;
}
// Agent icons
if (!cloudsOnly && !statsOnly) {
await refreshIconsFor(
"agent",
agentIds,
agents,
agentSources,
"assets/agents"
);
}
// Cloud icons
if (!agentsOnly && !statsOnly) {
await refreshIconsFor(
"cloud",
cloudIds,
clouds,
cloudSources,
"assets/clouds"
);
}
// Agent GitHub stats
if (!cloudsOnly && !iconsOnly) {
await refreshAgentStats();
}
// Validation
if (!cloudsOnly) validateAgentMetadata();
if (!agentsOnly) validateCloudIcons();
if (!dryRun) {
writeFileSync(
@ -208,11 +352,16 @@ async function main() {
"utf-8"
);
writeFileSync(
SOURCES_PATH,
JSON.stringify(sources, null, 2) + "\n",
AGENT_SOURCES_PATH,
JSON.stringify(agentSources, null, 2) + "\n",
"utf-8"
);
console.log("\n✓ manifest.json and .sources.json updated");
writeFileSync(
CLOUD_SOURCES_PATH,
JSON.stringify(cloudSources, null, 2) + "\n",
"utf-8"
);
console.log("\n✓ manifest.json and .sources.json files updated");
}
}

View file

@ -0,0 +1,9 @@
{
"hetzner": { "url": "https://www.hetzner.com/_resources/themes/hetzner/images/favicons/ms-icon-310x310.png", "ext": "png" },
"fly": { "url": "https://fly.io/phx/ui/images/favicon/android-chrome-512x512.png", "ext": "png" },
"aws": { "url": "https://a0.awsstatic.com/libra-css/images/site/touch-icon-ipad-144-smile.png", "ext": "png" },
"daytona": { "url": "https://avatars.githubusercontent.com/u/130513197?s=400&v=4", "ext": "png" },
"digitalocean": { "url": "https://www.digitalocean.com/_next/static/media/android-chrome-512x512.5f2e6221.png", "ext": "png" },
"gcp": { "url": "https://www.gstatic.com/cgc/super_cloud.png", "ext": "png" },
"sprite": { "url": "https://sprites.dev/images/favicon/apple-touch-icon.png", "ext": "png" }
}

BIN
assets/clouds/aws.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

BIN
assets/clouds/daytona.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/clouds/fly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
assets/clouds/gcp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
assets/clouds/hetzner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
assets/clouds/sprite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.5.13",
"version": "0.5.14",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -236,6 +236,13 @@ describe("Cloud optional field types (when present)", () => {
expect(cloud.notes!.length).toBeGreaterThan(0);
});
}
if (cloud.icon !== undefined) {
it(`cloud "${key}" icon should be a valid URL string`, () => {
expect(typeof cloud.icon).toBe("string");
expect(cloud.icon).toMatch(/^https?:\/\//);
});
}
}
});

View file

@ -44,6 +44,7 @@ export interface CloudDef {
interactive_method: string;
defaults?: Record<string, unknown>;
notes?: string;
icon?: string;
}
export interface Manifest {

View file

@ -206,7 +206,8 @@
"server_type": "cx23",
"location": "fsn1",
"image": "ubuntu-24.04"
}
},
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/clouds/hetzner.png"
},
"fly": {
"name": "Fly.io",
@ -223,7 +224,8 @@
"vm_memory": 1024,
"image": "ubuntu:24.04"
},
"notes": "Uses Machines API for provisioning and flyctl SSH for exec. Docker-based, pay-per-second pricing. Requires flyctl CLI."
"notes": "Uses Machines API for provisioning and flyctl SSH for exec. Docker-based, pay-per-second pricing. Requires flyctl CLI.",
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/clouds/fly.png"
},
"aws": {
"name": "AWS Lightsail",
@ -239,7 +241,8 @@
"region": "us-east-1",
"blueprint": "ubuntu_24_04"
},
"notes": "Uses 'ubuntu' user instead of 'root'. Requires AWS CLI installed and configured."
"notes": "Uses 'ubuntu' user instead of 'root'. Requires AWS CLI installed and configured.",
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/clouds/aws.png"
},
"daytona": {
"name": "Daytona",
@ -255,7 +258,8 @@
"memory": 2048,
"disk": 5
},
"notes": "Sub-90ms sandbox creation. True SSH support via daytona ssh. Requires DAYTONA_API_KEY from https://app.daytona.io."
"notes": "Sub-90ms sandbox creation. True SSH support via daytona ssh. Requires DAYTONA_API_KEY from https://app.daytona.io.",
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/clouds/daytona.png"
},
"digitalocean": {
"name": "DigitalOcean",
@ -270,7 +274,8 @@
"size": "s-2vcpu-2gb",
"region": "nyc3",
"image": "ubuntu-24-04-x64"
}
},
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/clouds/digitalocean.png"
},
"gcp": {
"name": "GCP Compute Engine",
@ -286,7 +291,8 @@
"zone": "us-central1-a",
"image_family": "ubuntu-2404-lts-amd64"
},
"notes": "Uses current username for SSH. Requires gcloud CLI installed and configured."
"notes": "Uses current username for SSH. Requires gcloud CLI installed and configured.",
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/clouds/gcp.png"
},
"sprite": {
"name": "Sprite",
@ -296,7 +302,8 @@
"auth": "sprite login",
"provision_method": "sprite create",
"exec_method": "sprite exec",
"interactive_method": "sprite exec -tty"
"interactive_method": "sprite exec -tty",
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/clouds/sprite.png"
}
},
"matrix": {