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:
A 2026-02-22 11:36:16 -08:00 committed by GitHub
parent 60986e5a05
commit 01ba7257ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 75 additions and 10 deletions

View file

@ -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", () => {

View file

@ -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);
}