diff --git a/cli/package.json b/cli/package.json index 4d99bea0..13eb6cd8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.25", + "version": "0.2.26", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/commands-display.test.ts b/cli/src/__tests__/commands-display.test.ts index 3e637b8d..240f20a3 100644 --- a/cli/src/__tests__/commands-display.test.ts +++ b/cli/src/__tests__/commands-display.test.ts @@ -386,26 +386,24 @@ describe("Commands Display Output", () => { expect(output).toContain("Hetzner Cloud"); }); - it("should show agent counts for each cloud", async () => { + it("should show agent counts for each cloud as ratio", async () => { await cmdClouds(); const output = consoleMocks.log.mock.calls .map((c: any[]) => c.join(" ")) .join("\n"); - // sprite has 2 agents, hetzner has 1 agent - expect(output).toContain("2 agents"); - expect(output).toContain("1 agent"); + // sprite has 2/2 agents, hetzner has 1/2 agents - shown as X/Y ratio + expect(output).toContain("2/2"); + expect(output).toContain("1/2"); }); - it("should show correct singular/plural for agent count", async () => { + it("should group clouds by type", async () => { await cmdClouds(); - const calls = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")); - // hetzner has 1 agent (singular) - const hetznerLine = calls.find( - (line: string) => line.includes("hetzner") && line.includes("agent") - ); - expect(hetznerLine).toBeDefined(); - expect(hetznerLine).toContain("1 agent"); - expect(hetznerLine).not.toContain("1 agents"); + const output = consoleMocks.log.mock.calls + .map((c: any[]) => c.join(" ")) + .join("\n"); + // Mock manifest has clouds with types "vm" and "cloud" + expect(output).toContain("vm"); + expect(output).toContain("cloud"); }); it("should show cloud descriptions", async () => { diff --git a/cli/src/__tests__/commands-output.test.ts b/cli/src/__tests__/commands-output.test.ts index cc3fd1f0..298b2a61 100644 --- a/cli/src/__tests__/commands-output.test.ts +++ b/cli/src/__tests__/commands-output.test.ts @@ -187,10 +187,9 @@ describe("Command Output Functions", () => { it("should show agent count per cloud", async () => { await cmdClouds(); const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - // sprite has 2 agents (claude, aider), hetzner has 1 (claude) - expect(output).toContain("2 agents"); - expect(output).toContain("1 agent"); - expect(output).not.toContain("1 agents"); + // sprite has 2 agents (claude, aider), hetzner has 1 (claude) - shown as X/Y ratio + expect(output).toContain("2/2"); + expect(output).toContain("1/2"); }); it("should show cloud descriptions", async () => { diff --git a/cli/src/commands.ts b/cli/src/commands.ts index aba819c0..28c6d91c 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -584,13 +584,29 @@ export async function cmdAgents(): Promise { export async function cmdClouds(): Promise { const manifest = await loadManifestWithSpinner(); + const allAgents = agentKeys(manifest); + const allClouds = cloudKeys(manifest); + + // Group clouds by type for easier scanning + const byType: Record = {}; + for (const key of allClouds) { + const type = manifest.clouds[key].type; + if (!byType[type]) byType[type] = []; + byType[type].push(key); + } + console.log(); - console.log(pc.bold("Cloud Providers")); - console.log(); - for (const key of cloudKeys(manifest)) { - const c = manifest.clouds[key]; - const implCount = getImplementedAgents(manifest, key).length; - console.log(` ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${c.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(`${implCount} agent${implCount !== 1 ? "s" : ""} ${c.description}`)}`); + console.log(pc.bold("Cloud Providers") + pc.dim(` (${allClouds.length} total)`)); + + 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}`; + console.log(` ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${c.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(`${countStr.padEnd(6)} ${c.description}`)}`); + } } console.log(); console.log(pc.dim(` Run ${pc.cyan("spawn ")} for details, or ${pc.cyan("spawn ")} to launch.`)); @@ -599,8 +615,6 @@ export async function cmdClouds(): Promise { // ── Agent Info ───────────────────────────────────────────────────────────────── -const TYPE_COLUMN_WIDTH = 10; - export async function cmdAgentInfo(agent: string): Promise { const [manifest, agentKey] = await validateAndGetAgent(agent); @@ -613,22 +627,33 @@ export async function cmdAgentInfo(agent: string): Promise { if (a.notes) { console.log(pc.dim(` ${a.notes}`)); } + + const allClouds = cloudKeys(manifest); + const implClouds = getImplementedClouds(manifest, agentKey); console.log(); - console.log(pc.bold("Available clouds:")); + console.log(pc.bold(`Available clouds:`) + pc.dim(` ${implClouds.length} of ${allClouds.length}`)); console.log(); - let found = false; - for (const cloud of cloudKeys(manifest)) { - const status = matrixStatus(manifest, cloud, agentKey); - if (status === "implemented") { - const c = manifest.clouds[cloud]; - console.log(` ${pc.green(cloud.padEnd(NAME_COLUMN_WIDTH))} ${c.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(c.type.padEnd(TYPE_COLUMN_WIDTH))}${pc.dim("spawn " + agentKey + " " + cloud)}`); - found = true; - } + if (implClouds.length === 0) { + console.log(pc.dim(" No implemented clouds yet.")); + console.log(); + return; } - if (!found) { - console.log(pc.dim(" No implemented clouds yet.")); + // Group implemented clouds by type for easier scanning + const byType: Record = {}; + for (const cloud of implClouds) { + const type = manifest.clouds[cloud].type; + if (!byType[type]) byType[type] = []; + byType[type].push(cloud); + } + + for (const [type, clouds] of Object.entries(byType)) { + console.log(` ${pc.dim(type)}`); + for (const cloud of clouds) { + const c = manifest.clouds[cloud]; + console.log(` ${pc.green(cloud.padEnd(NAME_COLUMN_WIDTH))} ${c.name.padEnd(NAME_COLUMN_WIDTH)} ${pc.dim("spawn " + agentKey + " " + cloud)}`); + } } console.log(); } @@ -657,32 +682,37 @@ export async function cmdCloudInfo(cloud: string): Promise { const c = manifest.clouds[cloudKey]; console.log(); console.log(`${pc.bold(c.name)} ${pc.dim("--")} ${c.description}`); - console.log(pc.dim(` Type: ${c.type}`)); + console.log(pc.dim(` Type: ${c.type} | Auth: ${c.auth}`)); if (c.url) { console.log(pc.dim(` ${c.url}`)); } if (c.notes) { console.log(pc.dim(` ${c.notes}`)); } + + const allAgents = agentKeys(manifest); + const implAgents = getImplementedAgents(manifest, cloudKey); + const missingAgents = allAgents.filter((a) => !implAgents.includes(a)); console.log(); - console.log(pc.bold("Available agents:")); + console.log(pc.bold(`Available agents:`) + pc.dim(` ${implAgents.length} of ${allAgents.length}`)); console.log(); - let found = false; - for (const agent of agentKeys(manifest)) { - const status = matrixStatus(manifest, cloudKey, agent); - if (status === "implemented") { + 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)}`); - found = true; } } - if (!found) { - console.log(pc.dim(" No implemented agents yet.")); + if (missingAgents.length > 0 && missingAgents.length <= 5) { + console.log(); + console.log(pc.dim(` Not yet available: ${missingAgents.map((a) => manifest.agents[a].name).join(", ")}`)); } + console.log(); - console.log(pc.dim(` Setup instructions: ${pc.cyan(`https://github.com/${REPO}/tree/main/${cloudKey}`)}`)); + console.log(pc.dim(` Setup: ${pc.cyan(`https://github.com/${REPO}/tree/main/${cloudKey}`)}`)); console.log(); }