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>
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
9
assets/clouds/.sources.json
Normal 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
|
After Width: | Height: | Size: 3 KiB |
BIN
assets/clouds/daytona.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
assets/clouds/digitalocean.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/clouds/fly.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/clouds/gcp.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
assets/clouds/hetzner.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
assets/clouds/sprite.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.5.13",
|
||||
"version": "0.5.14",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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?:\/\//);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export interface CloudDef {
|
|||
interactive_method: string;
|
||||
defaults?: Record<string, unknown>;
|
||||
notes?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||