Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.55",
"version": "0.2.56",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
14 changes: 7 additions & 7 deletions cli/src/__tests__/commands-compact-list.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand All@@ -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 () => {
Expand All@@ -255,22 +255,22 @@ 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;

await cmdMatrix();
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 () => {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/__tests__/dry-run-preview.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
});
});

Expand Down
22 changes: 14 additions & 8 deletions cli/src/__tests__/list-filter-suggestions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,18 +266,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -325,18 +328,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -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 () => {
Expand Down
34 changes: 23 additions & 11 deletions cli/src/commands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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}`)}`);
}
Expand DownExpand Up@@ -916,21 +918,31 @@ function buildRecordHint(r: SpawnRecord): string {
}

export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
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();
} catch {
// 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) => ({
Expand Down
8 changes: 7 additions & 1 deletion cli/src/history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.55",
"version": "0.2.56",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
14 changes: 7 additions & 7 deletions cli/src/__tests__/commands-compact-list.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand All@@ -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 () => {
Expand All@@ -255,22 +255,22 @@ 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;

await cmdMatrix();
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 () => {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/__tests__/dry-run-preview.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
});
});

Expand Down
22 changes: 14 additions & 8 deletions cli/src/__tests__/list-filter-suggestions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,18 +266,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -325,18 +328,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -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 () => {
Expand Down
34 changes: 23 additions & 11 deletions cli/src/commands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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}`)}`);
}
Expand DownExpand Up@@ -916,21 +918,31 @@ function buildRecordHint(r: SpawnRecord): string {
}

export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
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();
} catch {
// 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) => ({
Expand Down
8 changes: 7 additions & 1 deletion cli/src/history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.55",
"version": "0.2.56",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
14 changes: 7 additions & 7 deletions cli/src/__tests__/commands-compact-list.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand All@@ -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 () => {
Expand All@@ -255,22 +255,22 @@ 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;

await cmdMatrix();
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 () => {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/__tests__/dry-run-preview.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
});
});

Expand Down
22 changes: 14 additions & 8 deletions cli/src/__tests__/list-filter-suggestions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,18 +266,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -325,18 +328,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -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 () => {
Expand Down
34 changes: 23 additions & 11 deletions cli/src/commands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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}`)}`);
}
Expand DownExpand Up@@ -916,21 +918,31 @@ function buildRecordHint(r: SpawnRecord): string {
}

export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
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();
} catch {
// 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) => ({
Expand Down
8 changes: 7 additions & 1 deletion cli/src/history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.55",
"version": "0.2.56",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
14 changes: 7 additions & 7 deletions cli/src/__tests__/commands-compact-list.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand All@@ -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 () => {
Expand All@@ -255,22 +255,22 @@ 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;

await cmdMatrix();
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 () => {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/__tests__/dry-run-preview.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
});
});

Expand Down
22 changes: 14 additions & 8 deletions cli/src/__tests__/list-filter-suggestions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,18 +266,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -325,18 +328,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -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 () => {
Expand Down
34 changes: 23 additions & 11 deletions cli/src/commands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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}`)}`);
}
Expand DownExpand Up@@ -916,21 +918,31 @@ function buildRecordHint(r: SpawnRecord): string {
}

export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
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();
} catch {
// 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) => ({
Expand Down
8 changes: 7 additions & 1 deletion cli/src/history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.55",
"version": "0.2.56",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
14 changes: 7 additions & 7 deletions cli/src/__tests__/commands-compact-list.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand All@@ -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 () => {
Expand All@@ -255,22 +255,22 @@ 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;

await cmdMatrix();
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 () => {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/__tests__/dry-run-preview.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
});
});

Expand Down
22 changes: 14 additions & 8 deletions cli/src/__tests__/list-filter-suggestions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,18 +266,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -325,18 +328,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -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 () => {
Expand Down
34 changes: 23 additions & 11 deletions cli/src/commands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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}`)}`);
}
Expand DownExpand Up@@ -916,21 +918,31 @@ function buildRecordHint(r: SpawnRecord): string {
}

export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
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();
} catch {
// 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) => ({
Expand Down
8 changes: 7 additions & 1 deletion cli/src/history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.55",
"version": "0.2.56",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
14 changes: 7 additions & 7 deletions cli/src/__tests__/commands-compact-list.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand All@@ -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 () => {
Expand All@@ -255,22 +255,22 @@ 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;

await cmdMatrix();
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 () => {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/__tests__/dry-run-preview.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
});
});

Expand Down
22 changes: 14 additions & 8 deletions cli/src/__tests__/list-filter-suggestions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,18 +266,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -325,18 +328,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -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 () => {
Expand Down
34 changes: 23 additions & 11 deletions cli/src/commands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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}`)}`);
}
Expand DownExpand Up@@ -916,21 +918,31 @@ function buildRecordHint(r: SpawnRecord): string {
}

export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
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();
} catch {
// 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) => ({
Expand Down
8 changes: 7 additions & 1 deletion cli/src/history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.55",
"version": "0.2.56",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
14 changes: 7 additions & 7 deletions cli/src/__tests__/commands-compact-list.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand All@@ -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 () => {
Expand All@@ -255,22 +255,22 @@ 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;

await cmdMatrix();
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 () => {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/__tests__/dry-run-preview.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
});
});

Expand Down
22 changes: 14 additions & 8 deletions cli/src/__tests__/list-filter-suggestions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,18 +266,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -325,18 +328,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -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 () => {
Expand Down
34 changes: 23 additions & 11 deletions cli/src/commands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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}`)}`);
}
Expand DownExpand Up@@ -916,21 +918,31 @@ function buildRecordHint(r: SpawnRecord): string {
}

export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
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();
} catch {
// 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) => ({
Expand Down
8 changes: 7 additions & 1 deletion cli/src/history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.55",
"version": "0.2.56",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
14 changes: 7 additions & 7 deletions cli/src/__tests__/commands-compact-list.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 () => {
Expand All@@ -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 () => {
Expand All@@ -255,22 +255,22 @@ 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;

await cmdMatrix();
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 () => {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/__tests__/dry-run-preview.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
});
});

Expand Down
22 changes: 14 additions & 8 deletions cli/src/__tests__/list-filter-suggestions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,18 +266,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -325,18 +328,21 @@ 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" }])
);
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 () => {
Expand DownExpand Up@@ -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 () => {
Expand Down
34 changes: 23 additions & 11 deletions cli/src/commands.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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}`)}`);
}
Expand DownExpand Up@@ -916,21 +918,31 @@ function buildRecordHint(r: SpawnRecord): string {
}

export async function cmdList(agentFilter?: string, cloudFilter?: string): Promise<void> {
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();
} catch {
// 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) => ({
Expand Down
8 changes: 7 additions & 1 deletion cli/src/history.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
}

Expand Down