mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-20 01:11:18 +00:00
fix: don't re-prompt name for failed spawns, improve retry hint (#1719)
* fix: don't re-prompt name for failed spawns, improve retry hint - Reuse existing spawn name when rerunning from `spawn list` or `spawn last` instead of prompting for a new name (#1712) - Include --name flag in retry command hint when a spawn name was used, e.g. `spawn claude hetzner --name my-box` (#1709) - Bump CLI version to 0.6.5 Fixes #1712 Fixes #1709 Agent: ux-engineer Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test: add unit tests for buildRetryCommand --name flag Cover the spawnName parameter added for issue #1709: - with name and no prompt - with name and short prompt - with name and long prompt (prompt-file fallback) - with undefined/empty name (no --name flag) - verify --name appears before --prompt in output Agent: issue-fixer Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: quote --name value when spawn name contains spaces Handle edge case where spawn names may contain spaces or quotes by properly quoting and escaping the --name flag value. Agent: issue-fixer Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: always quote --name value to prevent shell injection Always wrap spawn names in double quotes in the retry command hint, not just when names contain spaces. This prevents shell metacharacters (;, |, &, etc.) in spawn names from being interpreted if users copy the displayed retry command. Agent: issue-fixer Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
60986e5a05
commit
01ba7257ed
2 changed files with 75 additions and 10 deletions
|
|
@ -632,6 +632,66 @@ describe("buildRetryCommand", () => {
|
|||
it("should return simple command when prompt is empty string", () => {
|
||||
expect(buildRetryCommand("codex", "vultr", "")).toBe("spawn codex vultr");
|
||||
});
|
||||
|
||||
// ── spawnName parameter (issue #1709) ────────────────────────────────────
|
||||
|
||||
it("should include --name flag when spawnName is provided without prompt", () => {
|
||||
expect(buildRetryCommand("claude", "hetzner", undefined, "my-box")).toBe(
|
||||
'spawn claude hetzner --name "my-box"'
|
||||
);
|
||||
});
|
||||
|
||||
it("should include --name flag when spawnName is provided with short prompt", () => {
|
||||
expect(buildRetryCommand("claude", "hetzner", "Fix all bugs", "my-box")).toBe(
|
||||
'spawn claude hetzner --name "my-box" --prompt "Fix all bugs"'
|
||||
);
|
||||
});
|
||||
|
||||
it("should include --name flag when spawnName is provided with long prompt", () => {
|
||||
const longPrompt = "A".repeat(100);
|
||||
const result = buildRetryCommand("claude", "hetzner", longPrompt, "my-box");
|
||||
expect(result).toBe('spawn claude hetzner --name "my-box" --prompt-file <your-prompt-file>');
|
||||
});
|
||||
|
||||
it("should not include --name flag when spawnName is undefined", () => {
|
||||
expect(buildRetryCommand("claude", "hetzner", undefined, undefined)).toBe(
|
||||
"spawn claude hetzner"
|
||||
);
|
||||
expect(buildRetryCommand("claude", "hetzner")).toBe("spawn claude hetzner");
|
||||
});
|
||||
|
||||
it("should not include --name flag when spawnName is empty string", () => {
|
||||
expect(buildRetryCommand("claude", "hetzner", undefined, "")).toBe(
|
||||
"spawn claude hetzner"
|
||||
);
|
||||
});
|
||||
|
||||
it("should place --name before --prompt in the command", () => {
|
||||
const result = buildRetryCommand("codex", "sprite", "short prompt", "dev-server");
|
||||
expect(result).toBe('spawn codex sprite --name "dev-server" --prompt "short prompt"');
|
||||
// Verify ordering: --name comes before --prompt
|
||||
const nameIdx = result.indexOf("--name");
|
||||
const promptIdx = result.indexOf("--prompt");
|
||||
expect(nameIdx).toBeLessThan(promptIdx);
|
||||
});
|
||||
|
||||
it("should quote --name value when it contains spaces", () => {
|
||||
expect(buildRetryCommand("claude", "hetzner", undefined, "my dev box")).toBe(
|
||||
'spawn claude hetzner --name "my dev box"'
|
||||
);
|
||||
});
|
||||
|
||||
it("should escape double quotes in --name value", () => {
|
||||
expect(buildRetryCommand("claude", "hetzner", undefined, 'my "box"')).toBe(
|
||||
'spawn claude hetzner --name "my \\"box\\""'
|
||||
);
|
||||
});
|
||||
|
||||
it("should always quote --name value to prevent shell injection", () => {
|
||||
// Names with shell metacharacters should be safely quoted
|
||||
const result = buildRetryCommand("claude", "hetzner", undefined, "foo; rm -rf");
|
||||
expect(result).toBe('spawn claude hetzner --name "foo; rm -rf"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard URL in guidance", () => {
|
||||
|
|
|
|||
|
|
@ -1258,17 +1258,19 @@ export function getScriptFailureGuidance(exitCode: number | null, cloud: string,
|
|||
return lines;
|
||||
}
|
||||
|
||||
export function buildRetryCommand(agent: string, cloud: string, prompt?: string): string {
|
||||
if (!prompt) return `spawn ${agent} ${cloud}`;
|
||||
export function buildRetryCommand(agent: string, cloud: string, prompt?: string, spawnName?: string): string {
|
||||
const safeName = spawnName ? spawnName.replace(/"/g, '\\"') : "";
|
||||
const nameFlag = spawnName ? ` --name "${safeName}"` : "";
|
||||
if (!prompt) return `spawn ${agent} ${cloud}${nameFlag}`;
|
||||
if (prompt.length <= 80) {
|
||||
const safe = prompt.replace(/"/g, '\\"');
|
||||
return `spawn ${agent} ${cloud} --prompt "${safe}"`;
|
||||
return `spawn ${agent} ${cloud}${nameFlag} --prompt "${safe}"`;
|
||||
}
|
||||
// Long prompts: suggest --prompt-file instead of truncating into a broken command
|
||||
return `spawn ${agent} ${cloud} --prompt-file <your-prompt-file>`;
|
||||
return `spawn ${agent} ${cloud}${nameFlag} --prompt-file <your-prompt-file>`;
|
||||
}
|
||||
|
||||
function reportScriptFailure(errMsg: string, cloud: string, agent: string, authHint?: string, prompt?: string, dashboardUrl?: string): never {
|
||||
function reportScriptFailure(errMsg: string, cloud: string, agent: string, authHint?: string, prompt?: string, dashboardUrl?: string, spawnName?: string): never {
|
||||
p.log.error("Spawn script failed");
|
||||
console.error("\nError:", errMsg);
|
||||
|
||||
|
|
@ -1285,7 +1287,7 @@ function reportScriptFailure(errMsg: string, cloud: string, agent: string, authH
|
|||
console.error("");
|
||||
for (const line of lines) console.error(line);
|
||||
console.error("");
|
||||
console.error(`Retry: ${pc.cyan(buildRetryCommand(agent, cloud, prompt))}`);
|
||||
console.error(`Retry: ${pc.cyan(buildRetryCommand(agent, cloud, prompt, spawnName))}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -1366,7 +1368,7 @@ async function execScript(cloud: string, agent: string, prompt?: string, authHin
|
|||
|
||||
const lastErr = await runWithRetries(scriptContent, prompt, dashboardUrl, debug, spawnName);
|
||||
if (lastErr) {
|
||||
reportScriptFailure(lastErr, cloud, agent, authHint, prompt, dashboardUrl);
|
||||
reportScriptFailure(lastErr, cloud, agent, authHint, prompt, dashboardUrl, spawnName);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1668,7 +1670,7 @@ async function showEmptyListMessage(agentFilter?: string, cloudFilter?: string):
|
|||
function buildListFooterLines(records: SpawnRecord[], agentFilter?: string, cloudFilter?: string): string[] {
|
||||
const lines: string[] = [];
|
||||
const latest = records[0];
|
||||
lines.push(`Rerun last: ${pc.cyan(buildRetryCommand(latest.agent, latest.cloud, latest.prompt))}`);
|
||||
lines.push(`Rerun last: ${pc.cyan(buildRetryCommand(latest.agent, latest.cloud, latest.prompt, latest.name))}`);
|
||||
|
||||
if (agentFilter || cloudFilter) {
|
||||
const totalRecords = filterHistory();
|
||||
|
|
@ -1949,7 +1951,8 @@ async function handleRecordAction(
|
|||
manifest: Manifest | null
|
||||
): Promise<void> {
|
||||
if (!selected.connection) {
|
||||
// No connection info -- just rerun
|
||||
// No connection info -- just rerun, reusing the existing spawn name
|
||||
if (selected.name) process.env.SPAWN_NAME = selected.name;
|
||||
p.log.step(`Spawning ${pc.bold(buildRecordLabel(selected, manifest))}`);
|
||||
await cmdRun(selected.agent, selected.cloud, selected.prompt);
|
||||
return;
|
||||
|
|
@ -2039,7 +2042,8 @@ async function handleRecordAction(
|
|||
return;
|
||||
}
|
||||
|
||||
// Rerun (create new spawn)
|
||||
// Rerun (create new spawn), reusing the existing spawn name
|
||||
if (selected.name) process.env.SPAWN_NAME = selected.name;
|
||||
p.log.step(`Spawning ${pc.bold(buildRecordLabel(selected, manifest))}`);
|
||||
await cmdRun(selected.agent, selected.cloud, selected.prompt);
|
||||
}
|
||||
|
|
@ -2182,6 +2186,7 @@ export async function cmdLast(): Promise<void> {
|
|||
const label = buildRecordLabel(latest, manifest);
|
||||
const hint = buildRecordHint(latest);
|
||||
p.log.step(`Rerunning last spawn: ${pc.bold(label)} ${pc.dim(hint)}`);
|
||||
if (latest.name) process.env.SPAWN_NAME = latest.name;
|
||||
await cmdRun(latest.agent, latest.cloud, latest.prompt);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue