feat(cli): add spawn name for each run (#1397)

Implements spawn name feature (#1372) to improve UX:
- Add optional spawn name prompt in interactive mode
- Pass spawn name via SPAWN_NAME env var to shell scripts
- Shell scripts use spawn name as default for resource names
- Store spawn name in history for future reference
- Bump CLI version to 0.4.0

The spawn name is prompted before agent/cloud selection and
automatically used as the default for platform-specific resource
names (server name on Hetzner, sprite name on Sprite, etc.).

Agent: ux-engineer

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-17 05:05:17 -08:00 committed by GitHub
parent 27e7f32da3
commit 7544dd0dcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 44 additions and 8 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.3.3",
"version": "0.4.0",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -447,10 +447,32 @@ async function selectCloud(manifest: Manifest, cloudList: string[], hintOverride
return cloudChoice;
}
// Prompt user to enter a spawn name for the instance
async function promptSpawnName(): Promise<string | undefined> {
const spawnName = await p.text({
message: "Enter a name for this spawn (optional)",
placeholder: "my-spawn",
validate: (value) => {
if (!value) return undefined; // Optional field
// Validate name format (alphanumeric, hyphens, underscores)
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return "Name must contain only letters, numbers, hyphens, and underscores";
}
if (value.length > 63) {
return "Name must be 63 characters or less";
}
return undefined;
},
});
if (p.isCancel(spawnName)) handleCancel();
return spawnName || undefined;
}
export async function cmdInteractive(): Promise<void> {
p.intro(pc.inverse(` spawn v${VERSION} `));
const manifest = await loadManifestWithSpinner();
const spawnName = await promptSpawnName();
const agentChoice = await selectAgent(manifest);
const { clouds, hintOverrides } = getAndValidateCloudChoices(manifest, agentChoice);
@ -464,7 +486,7 @@ export async function cmdInteractive(): Promise<void> {
p.log.info(`Next time, run directly: ${pc.cyan(`spawn ${agentChoice} ${cloudChoice}`)}`);
p.outro("Handing off to spawn script...");
await execScript(cloudChoice, agentChoice, undefined, getAuthHint(manifest, cloudChoice), manifest.clouds[cloudChoice].url);
await execScript(cloudChoice, agentChoice, undefined, getAuthHint(manifest, cloudChoice), manifest.clouds[cloudChoice].url, undefined, spawnName);
}
/** Interactive cloud selection when agent is already known (e.g. `spawn claude`) */
@ -472,6 +494,7 @@ export async function cmdAgentInteractive(agent: string, prompt?: string, dryRun
p.intro(pc.inverse(` spawn v${VERSION} `));
const manifest = await loadManifestWithSpinner();
const spawnName = await promptSpawnName();
const resolvedAgent = resolveAgentKey(manifest, agent);
if (!resolvedAgent) {
@ -495,7 +518,7 @@ export async function cmdAgentInteractive(agent: string, prompt?: string, dryRun
p.log.info(`Next time, run directly: ${pc.cyan(`spawn ${resolvedAgent} ${cloudChoice}`)}`);
p.outro("Handing off to spawn script...");
await execScript(cloudChoice, resolvedAgent, prompt, getAuthHint(manifest, cloudChoice), manifest.clouds[cloudChoice].url, dryRun);
await execScript(cloudChoice, resolvedAgent, prompt, getAuthHint(manifest, cloudChoice), manifest.clouds[cloudChoice].url, dryRun, spawnName);
}
// ── Run ────────────────────────────────────────────────────────────────────────
@ -1057,10 +1080,10 @@ function handleUserInterrupt(errMsg: string, dashboardUrl?: string): void {
process.exit(130);
}
async function runWithRetries(script: string, prompt?: string, dashboardUrl?: string, debug?: boolean): Promise<string | undefined> {
async function runWithRetries(script: string, prompt?: string, dashboardUrl?: string, debug?: boolean, spawnName?: string): Promise<string | undefined> {
for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
try {
await runBash(script, prompt, debug);
await runBash(script, prompt, debug, spawnName);
return undefined; // success
} catch (err) {
const errMsg = getErrorMessage(err);
@ -1079,7 +1102,7 @@ async function runWithRetries(script: string, prompt?: string, dashboardUrl?: st
return "Script failed after all retries";
}
async function execScript(cloud: string, agent: string, prompt?: string, authHint?: string, dashboardUrl?: string, debug?: boolean): Promise<void> {
async function execScript(cloud: string, agent: string, prompt?: string, authHint?: string, dashboardUrl?: string, debug?: boolean, spawnName?: string): Promise<void> {
const url = `https://openrouter.ai/labs/spawn/${cloud}/${agent}.sh`;
const ghUrl = `${RAW_BASE}/${cloud}/${agent}.sh`;
@ -1097,6 +1120,7 @@ async function execScript(cloud: string, agent: string, prompt?: string, authHin
agent,
cloud,
timestamp: new Date().toISOString(),
...(spawnName ? { name: spawnName } : {}),
...(prompt ? { prompt } : {}),
});
} catch (err) {
@ -1107,13 +1131,13 @@ async function execScript(cloud: string, agent: string, prompt?: string, authHin
}
}
const lastErr = await runWithRetries(scriptContent, prompt, dashboardUrl, debug);
const lastErr = await runWithRetries(scriptContent, prompt, dashboardUrl, debug, spawnName);
if (lastErr) {
reportScriptFailure(lastErr, cloud, agent, authHint, prompt, dashboardUrl);
}
}
function runBash(script: string, prompt?: string, debug?: boolean): Promise<void> {
function runBash(script: string, prompt?: string, debug?: boolean, spawnName?: string): Promise<void> {
// SECURITY: Validate script content before execution
validateScriptContent(script);
@ -1126,6 +1150,9 @@ function runBash(script: string, prompt?: string, debug?: boolean): Promise<void
if (debug) {
env.SPAWN_DEBUG = "1";
}
if (spawnName) {
env.SPAWN_NAME = spawnName;
}
return new Promise<void>((resolve, reject) => {
const child = spawn("bash", ["-c", script], {

View file

@ -18,6 +18,7 @@ export interface SpawnRecord {
agent: string;
cloud: string;
timestamp: string;
name?: string;
prompt?: string;
connection?: VMConnection;
}

View file

@ -453,12 +453,20 @@ get_resource_name() {
local prompt_text="${2}"
local resource_value="${!env_var_name}"
# First check platform-specific env var
if [[ -n "${resource_value}" ]]; then
log_info "Using ${prompt_text%:*} from environment: ${resource_value}"
echo "${resource_value}"
return 0
fi
# Then check for SPAWN_NAME (set by CLI)
if [[ -n "${SPAWN_NAME:-}" ]]; then
log_info "Using spawn name: ${SPAWN_NAME}"
echo "${SPAWN_NAME}"
return 0
fi
local name
name=$(safe_read "${prompt_text}")
if [[ -z "${name}" ]]; then