diff --git a/packages/cli/README.md b/packages/cli/README.md index 1f797948..fd72cfbf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -13,6 +13,7 @@ agentworkforce show [@] agentworkforce install [flags] agentworkforce sources agentworkforce harness check +agentworkforce destroy [--workspace ] [--cloud-url ] [--no-prompt] agentworkforce --version ``` @@ -29,6 +30,11 @@ agentworkforce --version - `sources` — list, add, or remove persona source directories. - `harness check` — probe which harnesses (`claude`, `codex`, `opencode`) are installed. See [`## Harness check`](#harness-check) below. +- `destroy` — tear down a deployed cloud agent: cancels all relaycron + schedules and marks the agent as destroyed. Accepts either a persona + JSON path (slug resolved via the workspace's agents index) or a literal + agent UUID. Exits `0` on success, `2` when the agent is unknown or + already destroyed, `1` for any other failure. - `--version` — print the installed package version. ## Install diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e15f2126..c7ba94be 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -59,6 +59,7 @@ import { } from '@relayfile/local-mount'; import ora, { type Ora } from 'ora'; import { runDeploy, runLogin } from './deploy-command.js'; +import { runDestroy } from './destroy-command.js'; import { startLaunchMetadataRecording, type LaunchMetadataRun @@ -202,6 +203,19 @@ Commands: --cloud-url override the workforce cloud URL --input KEY=value override a declared persona input (repeat for multiple) + destroy [flags] + Tear down a deployed agent: cancel all schedules and + mark the agent as destroyed in the workspace. Accepts + either a persona JSON path (resolved to an agent id + via the workspace's agents index) or a literal agent + UUID. + Flags: + --workspace pick a non-default workspace + --cloud-url override the workforce cloud URL + --no-prompt fail instead of opening the + browser login flow + Exit codes: 0 destroyed, 2 not found / already + destroyed, 1 any other error. login Connect this machine to a workforce workspace. The browser-based flow is rolling out; until then it prints the WORKFORCE_WORKSPACE_ID / WORKFORCE_WORKSPACE_TOKEN @@ -234,6 +248,8 @@ Examples: agentworkforce agent "$(agentworkforce pick "fix the flaky test in foo.test.ts")" agentworkforce deploy ./personas/weekly-digest.json --mode cloud agentworkforce deploy ./personas/weekly-digest.json --mode sandbox --input TOPIC="Deploy v1" + agentworkforce destroy ./personas/weekly-digest.json + agentworkforce destroy 11111111-2222-4333-8444-555555555555 agentworkforce login `; @@ -3773,6 +3789,11 @@ export async function main(): Promise { return; } + if (subcommand === 'destroy') { + await runDestroy(rest); + return; + } + if (subcommand === 'login') { await runLogin(rest); return; diff --git a/packages/cli/src/destroy-command.test.ts b/packages/cli/src/destroy-command.test.ts new file mode 100644 index 00000000..9dcda185 --- /dev/null +++ b/packages/cli/src/destroy-command.test.ts @@ -0,0 +1,391 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { parseDestroyArgs, runDestroy } from './destroy-command.js'; + +interface ExitTrap { + exits: number[]; + stderr: string; + stdout: string; + restore: () => void; +} + +function trapIO(): ExitTrap { + const trap: ExitTrap = { + exits: [], + stderr: '', + stdout: '', + restore: () => { + /* replaced below */ + } + }; + const origExit = process.exit; + const origErr = process.stderr.write.bind(process.stderr); + const origOut = process.stdout.write.bind(process.stdout); + const fakeExit = ((code?: number) => { + trap.exits.push(code ?? 0); + throw new Error(`__exit_trap__:${code ?? 0}`); + }) as typeof process.exit; + + process.exit = fakeExit; + process.stderr.write = ((chunk: string | Uint8Array) => { + trap.stderr += typeof chunk === 'string' ? chunk : chunk.toString(); + return true; + }) as typeof process.stderr.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + trap.stdout += typeof chunk === 'string' ? chunk : chunk.toString(); + return true; + }) as typeof process.stdout.write; + + trap.restore = () => { + process.exit = origExit; + process.stderr.write = origErr; + process.stdout.write = origOut; + }; + return trap; +} + +interface FetchCall { + url: string; + init: RequestInit | undefined; +} + +function trapFetch(handler: (call: FetchCall) => Response | Promise): { + calls: FetchCall[]; + restore: () => void; +} { + const calls: FetchCall[] = []; + const orig = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL).toString(); + const call: FetchCall = { url, init }; + calls.push(call); + return await handler(call); + }) as typeof globalThis.fetch; + return { + calls, + restore: () => { + globalThis.fetch = orig; + } + }; +} + +function withTokenEnv(token: string, workspace: string): () => void { + const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; + const prevWs = process.env.WORKFORCE_WORKSPACE_ID; + const prevCloudA = process.env.WORKFORCE_DEPLOY_CLOUD_URL; + const prevCloudB = process.env.WORKFORCE_CLOUD_URL; + process.env.WORKFORCE_WORKSPACE_TOKEN = token; + process.env.WORKFORCE_WORKSPACE_ID = workspace; + delete process.env.WORKFORCE_DEPLOY_CLOUD_URL; + delete process.env.WORKFORCE_CLOUD_URL; + return () => { + if (prevToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN; + else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken; + if (prevWs === undefined) delete process.env.WORKFORCE_WORKSPACE_ID; + else process.env.WORKFORCE_WORKSPACE_ID = prevWs; + if (prevCloudA !== undefined) process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA; + if (prevCloudB !== undefined) process.env.WORKFORCE_CLOUD_URL = prevCloudB; + }; +} + +const AGENT_UUID = '11111111-2222-4333-8444-555555555555'; +const WORKSPACE = 'ws-test'; +const CLOUD = 'https://cloud.example.test'; + +test('parseDestroyArgs: positional agent uuid only', () => { + const parsed = parseDestroyArgs([AGENT_UUID]); + assert.equal(parsed.target, AGENT_UUID); + assert.equal(parsed.workspace, undefined); + assert.equal(parsed.cloudUrl, undefined); + assert.equal(parsed.noPrompt, undefined); +}); + +test('parseDestroyArgs: persona path positional + flags', () => { + const parsed = parseDestroyArgs([ + './weekly.json', + '--workspace', + 'ws-1', + '--cloud-url=https://c.test', + '--no-prompt' + ]); + assert.equal(parsed.target, './weekly.json'); + assert.equal(parsed.workspace, 'ws-1'); + assert.equal(parsed.cloudUrl, 'https://c.test'); + assert.equal(parsed.noPrompt, true); +}); + +test('parseDestroyArgs: missing positional exits 1', () => { + const trap = trapIO(); + try { + assert.throws(() => parseDestroyArgs(['--workspace', 'ws-1']), /__exit_trap__:1/); + assert.deepEqual(trap.exits, [1]); + assert.match(trap.stderr, /missing persona path or agent id/); + } finally { + trap.restore(); + } +}); + +test('parseDestroyArgs: unknown flag exits 1', () => { + const trap = trapIO(); + try { + assert.throws(() => parseDestroyArgs([AGENT_UUID, '--bogus']), /__exit_trap__:1/); + assert.match(trap.stderr, /unknown flag "--bogus"/); + } finally { + trap.restore(); + } +}); + +test('runDestroy: happy path with agent UUID positional', async () => { + const restoreEnv = withTokenEnv('tok-1', WORKSPACE); + const fetchTrap = trapFetch(async (call) => { + assert.equal( + call.url, + `${CLOUD}/api/v1/workspaces/${WORKSPACE}/deployments/${AGENT_UUID}` + ); + assert.equal(call.init?.method, 'DELETE'); + assert.equal( + (call.init?.headers as Record | undefined)?.authorization, + 'Bearer tok-1' + ); + return new Response( + JSON.stringify({ + agentId: AGENT_UUID, + status: 'destroyed', + destroyedAt: '2026-05-13T00:00:00.000Z', + cancelledScheduleIds: ['sched_a', 'sched_b'] + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }); + const trap = trapIO(); + try { + await assert.rejects( + runDestroy([AGENT_UUID, '--cloud-url', CLOUD]), + /__exit_trap__:0/ + ); + assert.deepEqual(trap.exits, [0]); + assert.match(trap.stdout, new RegExp(`destroyed: ${AGENT_UUID}`)); + assert.match(trap.stdout, /cancelled schedules: 2/); + assert.equal(fetchTrap.calls.length, 1); + } finally { + trap.restore(); + fetchTrap.restore(); + restoreEnv(); + } +}); + +test('runDestroy: 404 maps to exit 2 (not-found / already destroyed)', async () => { + const restoreEnv = withTokenEnv('tok-1', WORKSPACE); + const fetchTrap = trapFetch( + async () => + new Response(JSON.stringify({ error: 'Agent not found', code: 'agent_not_found' }), { + status: 404, + headers: { 'content-type': 'application/json' } + }) + ); + const trap = trapIO(); + try { + await assert.rejects( + runDestroy([AGENT_UUID, '--cloud-url', CLOUD]), + /__exit_trap__:2/ + ); + assert.deepEqual(trap.exits, [2]); + assert.match(trap.stderr, /agent not found or already destroyed/); + } finally { + trap.restore(); + fetchTrap.restore(); + restoreEnv(); + } +}); + +test('runDestroy: 401 maps to exit 1 with a login hint', async () => { + const restoreEnv = withTokenEnv('tok-bad', WORKSPACE); + const fetchTrap = trapFetch( + async () => + new Response(JSON.stringify({ error: 'Unauthorized', code: 'unauthorized' }), { + status: 401, + headers: { 'content-type': 'application/json' } + }) + ); + const trap = trapIO(); + try { + await assert.rejects( + runDestroy([AGENT_UUID, '--cloud-url', CLOUD]), + /__exit_trap__:1/ + ); + assert.deepEqual(trap.exits, [1]); + assert.match(trap.stderr, /unauthorized/i); + assert.match(trap.stderr, /workforce login/i); + } finally { + trap.restore(); + fetchTrap.restore(); + restoreEnv(); + } +}); + +test('runDestroy: missing workspace exits 1', async () => { + // No WORKFORCE_WORKSPACE_ID, no --workspace. + const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; + const prevWs = process.env.WORKFORCE_WORKSPACE_ID; + process.env.WORKFORCE_WORKSPACE_TOKEN = 'tok-1'; + delete process.env.WORKFORCE_WORKSPACE_ID; + const fetchTrap = trapFetch(async () => { + throw new Error('fetch must not be called when workspace is missing'); + }); + const trap = trapIO(); + try { + await assert.rejects(runDestroy([AGENT_UUID]), /__exit_trap__:1/); + assert.deepEqual(trap.exits, [1]); + assert.match(trap.stderr, /no workspace resolved/); + assert.equal(fetchTrap.calls.length, 0); + } finally { + trap.restore(); + fetchTrap.restore(); + if (prevToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN; + else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken; + if (prevWs !== undefined) process.env.WORKFORCE_WORKSPACE_ID = prevWs; + } +}); + +test('runDestroy: persona path resolves to agent id via /agents lookup', async () => { + const restoreEnv = withTokenEnv('tok-1', WORKSPACE); + const tmp = await mkdtemp(path.join(os.tmpdir(), 'aw-destroy-')); + const personaPath = path.join(tmp, 'weekly-digest.json'); + await writeFile( + personaPath, + JSON.stringify({ id: 'weekly-digest', slug: 'weekly-digest', intent: 'review' }), + 'utf8' + ); + + const fetchTrap = trapFetch(async (call) => { + if (call.url.includes('/agents?')) { + assert.equal( + call.url, + `${CLOUD}/api/v1/workspaces/${WORKSPACE}/agents?persona_slug=weekly-digest` + ); + assert.equal(call.init?.method, 'GET'); + return new Response(JSON.stringify({ agent: { id: AGENT_UUID } }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + assert.equal( + call.url, + `${CLOUD}/api/v1/workspaces/${WORKSPACE}/deployments/${AGENT_UUID}` + ); + assert.equal(call.init?.method, 'DELETE'); + return new Response( + JSON.stringify({ + agentId: AGENT_UUID, + status: 'destroyed', + destroyedAt: '2026-05-13T00:00:00.000Z', + cancelledScheduleIds: [] + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }); + const trap = trapIO(); + try { + await assert.rejects( + runDestroy([personaPath, '--cloud-url', CLOUD]), + /__exit_trap__:0/ + ); + assert.deepEqual(trap.exits, [0]); + assert.match(trap.stdout, new RegExp(`destroyed: ${AGENT_UUID}`)); + assert.match(trap.stdout, /cancelled schedules: 0/); + assert.equal(fetchTrap.calls.length, 2); + } finally { + trap.restore(); + fetchTrap.restore(); + restoreEnv(); + await rm(tmp, { recursive: true, force: true }); + } +}); + +test('runDestroy: directory target is not treated as a persona file', async () => { + const restoreEnv = withTokenEnv('tok-1', WORKSPACE); + const tmp = await mkdtemp(path.join(os.tmpdir(), 'aw-destroy-dir-')); + const fetchTrap = trapFetch(async (call) => { + assert.equal( + call.url, + `${CLOUD}/api/v1/workspaces/${WORKSPACE}/deployments/${encodeURIComponent(tmp)}` + ); + assert.equal(call.init?.method, 'DELETE'); + return new Response( + JSON.stringify({ + agentId: tmp, + status: 'destroyed', + destroyedAt: '2026-05-13T00:00:00.000Z', + cancelledScheduleIds: [] + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + }); + const trap = trapIO(); + try { + await assert.rejects( + runDestroy([tmp, '--cloud-url', CLOUD]), + /__exit_trap__:0/ + ); + assert.deepEqual(trap.exits, [0]); + assert.equal(fetchTrap.calls.length, 1); + } finally { + trap.restore(); + fetchTrap.restore(); + restoreEnv(); + await rm(tmp, { recursive: true, force: true }); + } +}); + +test('runDestroy: persona path with no deployed agent returns exit 2', async () => { + const restoreEnv = withTokenEnv('tok-1', WORKSPACE); + const tmp = await mkdtemp(path.join(os.tmpdir(), 'aw-destroy-')); + const personaPath = path.join(tmp, 'orphan.json'); + await writeFile( + personaPath, + JSON.stringify({ id: 'orphan', slug: 'orphan', intent: 'review' }), + 'utf8' + ); + + const fetchTrap = trapFetch( + async () => + new Response('', { status: 404 }) + ); + const trap = trapIO(); + try { + await assert.rejects( + runDestroy([personaPath, '--cloud-url', CLOUD]), + /__exit_trap__:2/ + ); + assert.deepEqual(trap.exits, [2]); + assert.match(trap.stderr, /no deployed agent found for persona "orphan"/); + } finally { + trap.restore(); + fetchTrap.restore(); + restoreEnv(); + await rm(tmp, { recursive: true, force: true }); + } +}); + +test('runDestroy: 5xx server error exits 1 and surfaces the status', async () => { + const restoreEnv = withTokenEnv('tok-1', WORKSPACE); + const fetchTrap = trapFetch( + async () => new Response('boom', { status: 500 }) + ); + const trap = trapIO(); + try { + await assert.rejects( + runDestroy([AGENT_UUID, '--cloud-url', CLOUD]), + /__exit_trap__:1/ + ); + assert.deepEqual(trap.exits, [1]); + assert.match(trap.stderr, /500/); + } finally { + trap.restore(); + fetchTrap.restore(); + restoreEnv(); + } +}); diff --git a/packages/cli/src/destroy-command.ts b/packages/cli/src/destroy-command.ts new file mode 100644 index 00000000..0221dd44 --- /dev/null +++ b/packages/cli/src/destroy-command.ts @@ -0,0 +1,356 @@ +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { createTerminalIO, resolveWorkspaceToken } from '@agentworkforce/deploy'; + +const DEFAULT_CLOUD_URL = 'https://agentrelay.com'; +const USER_AGENT = 'agentworkforce-cli/destroy'; +// UUID v1-v5, what the cloud agents.id column emits. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export interface DestroyOptions { + /** Either a persona file path (.json) or a literal agent UUID. */ + target: string; + /** Workforce workspace id. Falls back to WORKFORCE_WORKSPACE_ID. */ + workspace?: string; + /** Override cloud base URL. Falls back to env, then DEFAULT_CLOUD_URL. */ + cloudUrl?: string; + /** Fail instead of opening the browser to log in. */ + noPrompt?: boolean; +} + +interface DestroyResponseBody { + agentId: string; + status: 'destroyed'; + destroyedAt: string; + cancelledScheduleIds: string[]; +} + +interface AgentLookupBody { + agent?: { id?: unknown; slug?: unknown } | null; + agents?: Array<{ id?: unknown; slug?: unknown }> | null; + id?: unknown; +} + +/** + * Internal sentinel error: lets `executeDestroy` choose a specific exit + * code (1 = generic error, 2 = not-found / already-destroyed) without + * calling `process.exit` directly inside helpers — which would clash + * with test traps that turn `exit` into a throw. + */ +class DestroyExit extends Error { + constructor(readonly exitCode: number, readonly userMessage: string) { + super(userMessage); + this.name = 'DestroyExit'; + } +} + +/** + * Argv parser + dispatcher for `agentworkforce destroy [flags]`. + * Mirrors the shape of runDeploy so cli.ts stays slim — destroy is a single + * remote DELETE plus user-friendly resolution from persona file -> agentId. + */ +export async function runDestroy(args: readonly string[]): Promise { + if (args.length === 0 || args[0] === '-h' || args[0] === '--help') { + process.stdout.write(DESTROY_USAGE); + process.exit(args.length === 0 ? 1 : 0); + } + + const parsed = parseDestroyArgs(args); + + let exitCode = 0; + try { + await executeDestroy(parsed); + } catch (err) { + if (err instanceof DestroyExit) { + if (err.userMessage) { + process.stderr.write(err.userMessage); + } + exitCode = err.exitCode; + } else { + process.stderr.write( + `\nagentworkforce destroy failed: ${err instanceof Error ? err.message : String(err)}\n` + ); + exitCode = 1; + } + } + process.exit(exitCode); +} + +async function executeDestroy(opts: DestroyOptions): Promise { + const workspace = (opts.workspace ?? process.env.WORKFORCE_WORKSPACE_ID ?? '').trim(); + if (!workspace) { + throw new DestroyExit( + 1, + '\nagentworkforce destroy failed: no workspace resolved: pass --workspace or set WORKFORCE_WORKSPACE_ID\n' + ); + } + + const cloudUrl = normalizeCloudUrl( + opts.cloudUrl ?? + process.env.WORKFORCE_DEPLOY_CLOUD_URL ?? + process.env.WORKFORCE_CLOUD_URL ?? + DEFAULT_CLOUD_URL + ); + + const io = createTerminalIO(); + const { token } = await resolveWorkspaceToken({ + workspace, + cloudUrl, + io, + ...(opts.noPrompt ? { noPrompt: true } : {}) + }); + + const agentId = await resolveAgentId({ + target: opts.target, + cloudUrl, + workspace, + token + }); + + const url = `${cloudUrl}/api/v1/workspaces/${encodeURIComponent( + workspace + )}/deployments/${encodeURIComponent(agentId)}`; + const res = await fetch(url, { + method: 'DELETE', + headers: { + authorization: `Bearer ${token}`, + 'user-agent': USER_AGENT + } + }); + + if (res.status === 401) { + throw new DestroyExit( + 1, + '\nagentworkforce destroy failed: unauthorized. Run `agentworkforce login` and retry.\n' + ); + } + if (res.status === 404) { + // Exit 2 is the documented "not found / already destroyed" signal so + // scripts can distinguish it from a generic 1. + throw new DestroyExit(2, `\nagent not found or already destroyed: ${agentId}\n`); + } + if (!res.ok) { + throw new DestroyExit( + 1, + `\nagentworkforce destroy failed: ${res.status} ${await responseExcerpt(res)}\n` + ); + } + + const body = (await res.json().catch(() => null)) as DestroyResponseBody | null; + if (!body || body.status !== 'destroyed' || typeof body.agentId !== 'string') { + throw new DestroyExit( + 1, + '\nagentworkforce destroy failed: server returned an unexpected response shape\n' + ); + } + + const count = Array.isArray(body.cancelledScheduleIds) ? body.cancelledScheduleIds.length : 0; + process.stdout.write(`destroyed: ${body.agentId}\ncancelled schedules: ${count}\n`); +} + +async function resolveAgentId(args: { + target: string; + cloudUrl: string; + workspace: string; + token: string; +}): Promise { + if (UUID_RE.test(args.target)) { + return args.target; + } + + const looksLikePersonaFile = + args.target.endsWith('.json') || (await pathExists(args.target)); + if (!looksLikePersonaFile) { + // Neither a UUID nor an obvious persona path — accept as-is so callers + // can pass deterministic slugs the server understands, but it will most + // likely 404 below and surface a clean error. + return args.target; + } + + const absPath = path.resolve(args.target); + const raw = await readFile(absPath, 'utf8').catch((err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + throw new Error(`persona JSON not found at ${absPath}`); + } + throw err; + }); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error( + `persona JSON at ${absPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` + ); + } + if (typeof parsed !== 'object' || parsed === null) { + throw new Error(`persona JSON at ${absPath} must be a top-level object`); + } + const slug = (parsed as { slug?: unknown; id?: unknown }).slug; + const id = (parsed as { id?: unknown }).id; + const lookupSlug = + typeof slug === 'string' && slug.trim() + ? slug.trim() + : typeof id === 'string' && id.trim() + ? id.trim() + : null; + if (!lookupSlug) { + throw new Error(`persona JSON at ${absPath} is missing "id" / "slug"`); + } + + const url = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent( + args.workspace + )}/agents?persona_slug=${encodeURIComponent(lookupSlug)}`; + const res = await fetch(url, { + method: 'GET', + headers: { + authorization: `Bearer ${args.token}`, + 'user-agent': USER_AGENT + } + }); + + if (res.status === 401) { + throw new Error('unauthorized while looking up the deployed agent: run `agentworkforce login` and retry'); + } + // 404/405 here means the server doesn't have a deployed agent for this + // persona — surface as the same exit-2 signal we use for explicit + // already-destroyed targets. + if (res.status === 404 || res.status === 405) { + throw new DestroyExit(2, `\nno deployed agent found for persona "${lookupSlug}"\n`); + } + if (!res.ok) { + throw new Error( + `agent lookup failed: ${res.status} ${await responseExcerpt(res)}; pass the agent UUID directly` + ); + } + + const body = (await res.json().catch(() => null)) as AgentLookupBody | null; + const resolved = extractAgentId(body); + if (!resolved) { + throw new Error( + `agent lookup for "${lookupSlug}" returned no agent id; pass the agent UUID directly` + ); + } + return resolved; +} + +function extractAgentId(body: AgentLookupBody | null): string | null { + if (!body) return null; + if (typeof body.id === 'string' && body.id.trim()) return body.id.trim(); + if (body.agent && typeof body.agent.id === 'string' && body.agent.id.trim()) { + return body.agent.id.trim(); + } + if (Array.isArray(body.agents)) { + for (const candidate of body.agents) { + if (candidate && typeof candidate.id === 'string' && candidate.id.trim()) { + return candidate.id.trim(); + } + } + } + return null; +} + +async function pathExists(target: string): Promise { + try { + const stats = await stat(path.resolve(target)); + return stats.isFile(); + } catch { + return false; + } +} + +async function responseExcerpt(res: Response): Promise { + try { + const text = (await res.text()).trim(); + return text.length > 200 ? `${text.slice(0, 200)}…` : text; + } catch { + return ''; + } +} + +function normalizeCloudUrl(url: string): string { + const trimmed = url.trim(); + return trimmed ? trimmed.replace(/\/+$/, '') : DEFAULT_CLOUD_URL; +} + +export const DESTROY_USAGE = `usage: agentworkforce destroy [flags] + +Tear down a deployed agent: cancel all relaycron schedules and mark the +agent as destroyed in the workspace. Accepts either a persona JSON path +(slug/id is resolved via the workspace's agents index) or a literal agent +UUID. + +Flags: + --workspace Workforce workspace; defaults to WORKFORCE_WORKSPACE_ID + --cloud-url Override the workforce cloud base URL + --no-prompt Fail instead of opening the browser to log in + -h, --help Print this message + +Exit codes: + 0 destroyed + 2 agent not found or already destroyed + 1 any other error +`; + +export function parseDestroyArgs(args: readonly string[]): DestroyOptions { + let target: string | undefined; + let workspace: string | undefined; + let cloudUrl: string | undefined; + let noPrompt = false; + + for (let i = 0; i < args.length; i += 1) { + const a = args[i]; + if (a === '-h' || a === '--help') { + process.stdout.write(DESTROY_USAGE); + process.exit(0); + } else if (a === '--workspace') { + workspace = expectValue('--workspace', args[++i]); + } else if (a.startsWith('--workspace=')) { + workspace = expectInlineValue('--workspace', a.slice('--workspace='.length)); + } else if (a === '--cloud-url') { + cloudUrl = expectValue('--cloud-url', args[++i]); + } else if (a.startsWith('--cloud-url=')) { + cloudUrl = expectInlineValue('--cloud-url', a.slice('--cloud-url='.length)); + } else if (a === '--no-prompt') { + noPrompt = true; + } else if (a.startsWith('--')) { + die(`destroy: unknown flag "${a}"`); + } else if (!target) { + target = a; + } else { + die(`destroy: unexpected positional argument "${a}"`); + } + } + + if (!target) { + die('destroy: missing persona path or agent id. Usage: agentworkforce destroy '); + } + + return { + target, + ...(workspace ? { workspace } : {}), + ...(cloudUrl ? { cloudUrl } : {}), + ...(noPrompt ? { noPrompt: true } : {}) + }; +} + +function expectValue(flag: string, value: string | undefined): string { + if (typeof value !== 'string' || !value.trim()) { + die(`${flag}: missing value`); + } + if (value.startsWith('-')) { + die(`${flag}: missing value (got "${value}", which looks like a flag)`); + } + return value; +} + +function expectInlineValue(flag: string, value: string): string { + if (!value.trim()) { + die(`${flag}: missing value`); + } + return value; +} + +function die(message: string): never { + process.stderr.write(`${message}\n`); + process.exit(1); +}