feat: headless delete via spawn delete --name <name> --yes (#3015)

Agents running on spawned VMs couldn't delete child spawns because
`spawn delete` requires an interactive terminal for the picker UI.

Added --name and --yes flags: when both are provided in non-interactive
mode, the server matching the name is deleted without prompts. This
enables agents to manage their own child VMs programmatically.

Updated all skill files to teach agents the headless delete syntax.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
This commit is contained in:
Ahmed Abushagur 2026-03-26 12:30:15 -07:00 committed by GitHub
parent 73bb52e2f5
commit 0f48e4dae5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 49 additions and 15 deletions

View file

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

View file

@ -352,7 +352,12 @@ export async function cascadeDelete(record: SpawnRecord, manifest: Manifest | nu
return confirmAndDelete(record, manifest); return confirmAndDelete(record, manifest);
} }
export async function cmdDelete(agentFilter?: string, cloudFilter?: string): Promise<void> { export async function cmdDelete(
agentFilter?: string,
cloudFilter?: string,
nameFilter?: string,
forceYes?: boolean,
): Promise<void> {
const resolved = await resolveListFilters(agentFilter, cloudFilter); const resolved = await resolveListFilters(agentFilter, cloudFilter);
agentFilter = resolved.agentFilter; agentFilter = resolved.agentFilter;
cloudFilter = resolved.cloudFilter; cloudFilter = resolved.cloudFilter;
@ -368,6 +373,15 @@ export async function cmdDelete(agentFilter?: string, cloudFilter?: string): Pro
const lower = cloudFilter.toLowerCase(); const lower = cloudFilter.toLowerCase();
filtered = filtered.filter((r) => r.cloud.toLowerCase() === lower); filtered = filtered.filter((r) => r.cloud.toLowerCase() === lower);
} }
if (nameFilter) {
const lower = nameFilter.toLowerCase();
filtered = filtered.filter(
(r) =>
(r.name ?? "").toLowerCase() === lower ||
(r.connection?.server_name ?? "").toLowerCase() === lower ||
r.id === nameFilter,
);
}
if (filtered.length === 0) { if (filtered.length === 0) {
p.log.info("No active servers to delete."); p.log.info("No active servers to delete.");
@ -387,10 +401,22 @@ export async function cmdDelete(agentFilter?: string, cloudFilter?: string): Pro
const manifestResult = await asyncTryCatchIf(isNetworkError, loadManifest); const manifestResult = await asyncTryCatchIf(isNetworkError, loadManifest);
const manifest: Manifest | null = manifestResult.ok ? manifestResult.data : null; const manifest: Manifest | null = manifestResult.ok ? manifestResult.data : null;
// Non-interactive headless delete: --name + --yes skips the picker
if (!isInteractiveTTY()) { if (!isInteractiveTTY()) {
p.log.error("spawn delete requires an interactive terminal."); if (!forceYes) {
p.log.info(`Use ${pc.cyan("spawn list")} to see your servers.`); p.log.error("spawn delete requires --yes in non-interactive mode.");
process.exit(1); p.log.info(`Usage: ${pc.cyan("spawn delete --name <name> --yes")}`);
process.exit(1);
}
for (const record of filtered) {
const label = record.connection?.server_name || record.name || record.id;
await ensureDeleteCredentials(record);
const ok = await execDeleteServer(record);
if (ok) {
p.log.success(`Server "${label}" deleted`);
}
}
return;
} }
await activeServerPicker(filtered, manifest); await activeServerPicker(filtered, manifest);

View file

@ -613,8 +613,16 @@ async function dispatchDeleteCommand(filteredArgs: string[]): Promise<void> {
cmdHelp(); cmdHelp();
return; return;
} }
const { agentFilter, cloudFilter } = parseListFilters(filteredArgs.slice(1)); const args = filteredArgs.slice(1);
await cmdDelete(agentFilter, cloudFilter); const forceYes = args.includes("--yes") || args.includes("-y");
let nameFilter: string | undefined;
const nameIdx = args.indexOf("--name");
if (nameIdx !== -1 && args[nameIdx + 1]) {
nameFilter = args[nameIdx + 1];
}
const cleanArgs = args.filter((a) => a !== "--yes" && a !== "-y" && a !== "--name" && a !== nameFilter);
const { agentFilter, cloudFilter } = parseListFilters(cleanArgs);
await cmdDelete(agentFilter, cloudFilter, nameFilter, forceYes);
} }
/** Handle status/ps commands with --prune and --json flags */ /** Handle status/ps commands with --prune and --json flags */

View file

@ -44,7 +44,7 @@ Returns JSON: \`{"status":"success","ip_address":"...","ssh_user":"root","server
## Managing Children ## Managing Children
- \`spawn list --json\` — see running children - \`spawn list --json\` — see running children
- \`spawn delete\` — tear down a child VM - \`spawn delete --name <name> --yes\` — tear down a child VM (headless)
- \`spawn tree\` — see the full spawn tree - \`spawn tree\` — see the full spawn tree
## Context ## Context

View file

@ -28,7 +28,7 @@ Returns JSON: `{"status":"success","ip_address":"...","ssh_user":"root","server_
## Managing Children ## Managing Children
- `spawn list --json` — see running children - `spawn list --json` — see running children
- `spawn delete` — tear down a child VM - `spawn delete --name <name> --yes` — tear down a child VM
- `spawn tree` — see the full spawn tree - `spawn tree` — see the full spawn tree
## Context ## Context

View file

@ -28,7 +28,7 @@ Returns JSON: `{"status":"success","ip_address":"...","ssh_user":"root","server_
## Managing Children ## Managing Children
- `spawn list --json` — see running children - `spawn list --json` — see running children
- `spawn delete` — tear down a child VM - `spawn delete --name <name> --yes` — tear down a child VM
- `spawn tree` — see the full spawn tree - `spawn tree` — see the full spawn tree
## Context ## Context

View file

@ -22,7 +22,7 @@ Returns JSON: `{"status":"success","ip_address":"...","ssh_user":"root","server_
## Managing Children ## Managing Children
- `spawn list --json` — see running children - `spawn list --json` — see running children
- `spawn delete` — tear down a child VM - `spawn delete --name <name> --yes` — tear down a child VM
- `spawn tree` — see the full spawn tree - `spawn tree` — see the full spawn tree
## Context ## Context

View file

@ -22,7 +22,7 @@ Returns JSON: `{"status":"success","ip_address":"...","ssh_user":"root","server_
## Managing Children ## Managing Children
- `spawn list --json` — see running children - `spawn list --json` — see running children
- `spawn delete` — tear down a child VM - `spawn delete --name <name> --yes` — tear down a child VM
- `spawn tree` — see the full spawn tree - `spawn tree` — see the full spawn tree
## Context ## Context

View file

@ -28,7 +28,7 @@ Returns JSON: `{"status":"success","ip_address":"...","ssh_user":"root","server_
## Managing Children ## Managing Children
- `spawn list --json` — see running children - `spawn list --json` — see running children
- `spawn delete` — tear down a child VM - `spawn delete --name <name> --yes` — tear down a child VM
- `spawn tree` — see the full spawn tree - `spawn tree` — see the full spawn tree
## Context ## Context

View file

@ -22,7 +22,7 @@ Returns JSON: `{"status":"success","ip_address":"...","ssh_user":"root","server_
## Managing Children ## Managing Children
- `spawn list --json` — see running children - `spawn list --json` — see running children
- `spawn delete` — tear down a child VM - `spawn delete --name <name> --yes` — tear down a child VM
- `spawn tree` — see the full spawn tree - `spawn tree` — see the full spawn tree
## Context ## Context

View file

@ -22,7 +22,7 @@ Returns JSON: `{"status":"success","ip_address":"...","ssh_user":"root","server_
## Managing Children ## Managing Children
- `spawn list --json` — see running children - `spawn list --json` — see running children
- `spawn delete` — tear down a child VM - `spawn delete --name <name> --yes` — tear down a child VM
- `spawn tree` — see the full spawn tree - `spawn tree` — see the full spawn tree
## Context ## Context