diff --git a/cli/package.json b/cli/package.json index 07199f26..86f2054a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.55", + "version": "0.2.56", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/commands-compact-list.test.ts b/cli/src/__tests__/commands-compact-list.test.ts index f58bf7f0..dd827ce8 100644 --- a/cli/src/__tests__/commands-compact-list.test.ts +++ b/cli/src/__tests__/commands-compact-list.test.ts @@ -224,10 +224,10 @@ describe("Compact List View", () => { await cmdMatrix(); const output = getOutput(); - // Compact view has "Agent", "Clouds", "Missing" header columns + // Compact view has "Agent", "Clouds", "Not yet available" header columns expect(output).toContain("Agent"); expect(output).toContain("Clouds"); - expect(output).toContain("Missing"); + expect(output).toContain("Not yet available"); }); it("should use grid view when terminal is wide enough for small manifest", async () => { @@ -241,8 +241,8 @@ describe("Compact List View", () => { expect(output).toContain("+"); expect(output).toContain("Sprite"); expect(output).toContain("Hetzner Cloud"); - // Grid view should NOT have the "Missing" header column - expect(output).not.toContain("Missing"); + // Grid view should NOT have the "Not yet available" header column + expect(output).not.toContain("Not yet available"); }); it("should default to 80 columns when process.stdout.columns is undefined", async () => { @@ -255,14 +255,14 @@ describe("Compact List View", () => { // With 7 clouds at ~10+ chars each, the grid would be ~100+ chars // which exceeds the 80-column default, so compact view should trigger expect(output).toContain("Agent"); - expect(output).toContain("Missing"); + expect(output).toContain("Not yet available"); }); }); // ── Compact view header and structure ───────────────────────────── describe("compact view header", () => { - it("should show three column headers: Agent, Clouds, Missing", async () => { + it("should show three column headers: Agent, Clouds, Not yet available", async () => { await setManifest(wideManifest); process.stdout.columns = 60; @@ -270,7 +270,7 @@ describe("Compact List View", () => { const output = getOutput(); expect(output).toContain("Agent"); expect(output).toContain("Clouds"); - expect(output).toContain("Missing"); + expect(output).toContain("Not yet available"); }); it("should include a separator line with dashes", async () => { diff --git a/cli/src/__tests__/dry-run-preview.test.ts b/cli/src/__tests__/dry-run-preview.test.ts index 8e65f51d..f2aec322 100644 --- a/cli/src/__tests__/dry-run-preview.test.ts +++ b/cli/src/__tests__/dry-run-preview.test.ts @@ -391,12 +391,12 @@ describe("Dry-run preview (showDryRunPreview via cmdRun)", () => { expect(getLogText()).toContain("sprite/claude.sh"); }); - it("should use openrouter.ai lab URL", async () => { + it("should use GitHub raw URL", async () => { setupManifest(standardManifest); await loadManifest(true); await cmdRun("claude", "sprite", undefined, true); - expect(getLogText()).toContain("openrouter.ai/lab/spawn"); + expect(getLogText()).toContain("raw.githubusercontent.com/OpenRouterTeam/spawn/main"); }); }); diff --git a/cli/src/__tests__/list-filter-suggestions.test.ts b/cli/src/__tests__/list-filter-suggestions.test.ts index 1f86608f..152270d3 100644 --- a/cli/src/__tests__/list-filter-suggestions.test.ts +++ b/cli/src/__tests__/list-filter-suggestions.test.ts @@ -266,7 +266,7 @@ describe("cmdList - filter suggestions", () => { expect(infoCalls.some((msg: string) => msg.includes("Did you mean") && msg.includes("claude"))).toBe(true); }); - it("should suggest agent when filter matches display name case-insensitively", async () => { + it("should resolve display name to key and find matching records", async () => { writeFileSync( join(testDir, "history.json"), JSON.stringify([{ agent: "claude", cloud: "sprite", timestamp: "2026-01-01T00:00:00Z" }]) @@ -274,10 +274,13 @@ describe("cmdList - filter suggestions", () => { await setManifest(mockManifest); // "Claude Code" resolves to "claude" via resolveAgentKey display name match + // so records should be found directly without needing a suggestion await cmdList("Claude Code"); - const infoCalls = getAllClackInfo(); - expect(infoCalls.some((msg: string) => msg.includes("Did you mean") && msg.includes("claude"))).toBe(true); + const logCalls = consoleMocks.log.mock.calls.map((c: any[]) => String(c[0] ?? "")).join("\n"); + // Should find the record and show it (table header visible) + expect(logCalls).toContain("AGENT"); + expect(logCalls).toContain("claude"); }); it("should not suggest when agent filter is completely unrelated", async () => { @@ -325,7 +328,7 @@ describe("cmdList - filter suggestions", () => { expect(infoCalls.some((msg: string) => msg.includes("Did you mean") && msg.includes("sprite"))).toBe(true); }); - it("should suggest cloud when filter matches display name", async () => { + it("should resolve cloud display name to key and find matching records", async () => { writeFileSync( join(testDir, "history.json"), JSON.stringify([{ agent: "claude", cloud: "hetzner", timestamp: "2026-01-01T00:00:00Z" }]) @@ -333,10 +336,13 @@ describe("cmdList - filter suggestions", () => { await setManifest(mockManifest); // "Hetzner Cloud" resolves to "hetzner" via resolveCloudKey display name match + // so records should be found directly without needing a suggestion await cmdList(undefined, "Hetzner Cloud"); - const infoCalls = getAllClackInfo(); - expect(infoCalls.some((msg: string) => msg.includes("Did you mean") && msg.includes("hetzner"))).toBe(true); + const logCalls = consoleMocks.log.mock.calls.map((c: any[]) => String(c[0] ?? "")).join("\n"); + // Should find the record and show it (table header visible) + expect(logCalls).toContain("AGENT"); + expect(logCalls).toContain("hetzner"); }); it("should not suggest cloud when filter is completely unrelated", async () => { @@ -620,10 +626,10 @@ describe("cmdMatrix - compact vs grid view", () => { await cmdMatrix(); const output = getOutput(); - // Compact view shows "all clouds supported" or "Missing" column + // Compact view shows "all clouds supported" or "Not yet available" column expect(output).toContain("Agent"); expect(output).toContain("Clouds"); - expect(output).toContain("Missing"); + expect(output).toContain("Not yet available"); }); it("should show green for fully-supported agents in compact view", async () => { diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 867f725a..2dbd209e 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -386,7 +386,7 @@ function showDryRunPreview(manifest: Manifest, agent: string, cloud: string, pro printDryRunSection("Agent", buildAgentLines(manifest.agents[agent])); printDryRunSection("Cloud", buildCloudLines(manifest.clouds[cloud])); - printDryRunSection("Script", [` URL: https://openrouter.ai/lab/spawn/${cloud}/${agent}.sh`]); + printDryRunSection("Script", [` URL: ${RAW_BASE}/${cloud}/${agent}.sh`]); const env = manifest.agents[agent].env; if (env) { @@ -706,7 +706,7 @@ function renderCompactList(manifest: Manifest, agents: string[], clouds: string[ const totalClouds = clouds.length; console.log(); - console.log(pc.bold("Agent".padEnd(COMPACT_NAME_WIDTH)) + pc.bold("Clouds".padEnd(COMPACT_COUNT_WIDTH)) + pc.bold("Missing")); + 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) { @@ -849,7 +849,9 @@ function showListFooter(records: SpawnRecord[], agentFilter?: string, cloudFilte const latest = records[0]; if (latest.prompt) { const shortPrompt = latest.prompt.length > 30 ? latest.prompt.slice(0, 30) + "..." : latest.prompt; - console.log(`Rerun last: ${pc.cyan(`spawn ${latest.agent} ${latest.cloud} --prompt "${shortPrompt}"`)}`); + // Escape double quotes so the suggested command is valid shell + const safePrompt = shortPrompt.replace(/"/g, '\\"'); + console.log(`Rerun last: ${pc.cyan(`spawn ${latest.agent} ${latest.cloud} --prompt "${safePrompt}"`)}`); } else { console.log(`Rerun last: ${pc.cyan(`spawn ${latest.agent} ${latest.cloud}`)}`); } @@ -916,14 +918,7 @@ function buildRecordHint(r: SpawnRecord): string { } export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise { - const records = filterHistory(agentFilter, cloudFilter); - - if (records.length === 0) { - await showEmptyListMessage(agentFilter, cloudFilter); - return; - } - - // Try to load manifest for display names (fall back to raw keys if unavailable) + // Try to load manifest early so we can resolve display names in filters let manifest: Manifest | null = null; try { manifest = await loadManifest(); @@ -931,6 +926,23 @@ export async function cmdList(agentFilter?: string, cloudFilter?: string): Promi // Manifest unavailable -- show raw keys } + // Resolve display names to keys (e.g., "Claude Code" -> "claude") + if (manifest && agentFilter) { + const resolved = resolveAgentKey(manifest, agentFilter); + if (resolved) agentFilter = resolved; + } + if (manifest && cloudFilter) { + const resolved = resolveCloudKey(manifest, cloudFilter); + if (resolved) cloudFilter = resolved; + } + + const records = filterHistory(agentFilter, cloudFilter); + + if (records.length === 0) { + await showEmptyListMessage(agentFilter, cloudFilter); + return; + } + // Interactive mode: show a select picker so user can choose a spawn to rerun if (isInteractiveTTY()) { const options = records.map((r, i) => ({ diff --git a/cli/src/history.ts b/cli/src/history.ts index 8cadb035..64b1a475 100644 --- a/cli/src/history.ts +++ b/cli/src/history.ts @@ -29,13 +29,19 @@ export function loadHistory(): SpawnRecord[] { } } +const MAX_HISTORY_ENTRIES = 100; + export function saveSpawnRecord(record: SpawnRecord): void { const dir = getSpawnDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - const history = loadHistory(); + let history = loadHistory(); history.push(record); + // Trim to most recent entries to prevent unbounded growth + if (history.length > MAX_HISTORY_ENTRIES) { + history = history.slice(history.length - MAX_HISTORY_ENTRIES); + } writeFileSync(getHistoryPath(), JSON.stringify(history, null, 2) + "\n"); }