From c4a4fa4bdeed563e3c98ab3b20c21e47a308d531 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sun, 21 Jun 2026 13:33:56 +0200 Subject: [PATCH] feat(cli): add deployed persona trigger command --- packages/cli/README.md | 4 + packages/cli/src/cli.ts | 23 ++- packages/cli/src/trigger-command.test.ts | 94 ++++++++++ packages/cli/src/trigger-command.ts | 223 +++++++++++++++++++++++ 4 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/trigger-command.test.ts create mode 100644 packages/cli/src/trigger-command.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 319cea6d..392488ac 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,6 +14,7 @@ agentworkforce persona compile agentworkforce install [flags] agentworkforce deploy [flags] agentworkforce integrations [provider] [--all] [--json] +agentworkforce trigger [--workspace ] [--cloud-url ] [--json] [--no-prompt] agentworkforce sources agentworkforce harness check agentworkforce destroy [--workspace ] [--cloud-url ] [--no-prompt] @@ -36,6 +37,9 @@ agentworkforce --version an authored source module such as `persona.ts` or `persona.js`. - `integrations` — discover available integrations, known trigger events, and connection status for the active workspace. +- `trigger` — manually fire an active deployed persona for testing. The + selector accepts agent id, compact agent id, deployed name, persona slug, or + persona id, and posts to the same cloud trigger endpoint used by the dashboard. - `sources` — list, add, or remove persona source directories. - `harness check` — probe which harnesses (`claude`, `codex`, `opencode`, `grok`) are installed. See [`## Harness check`](#harness-check) below. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c4705fd4..7c1d1685 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -105,6 +105,7 @@ import { installPersonas, type PersonaInstallResult } from './persona-install.js import { runPersonaCompileCommand } from './persona-compile.js'; import { pickPersona, type PickCandidate, type PickResult } from './persona-picker.js'; import { recordRecent, loadRecents, runPersonaPickerTui, type TuiCandidate } from './persona-tui.js'; +import { runTrigger } from './trigger-command.js'; const launchMetadataLog = createLogger('launch-metadata'); @@ -230,6 +231,15 @@ Commands: integrations [provider] [--all] [--json] Discover workspace integrations, connection status, and known trigger events. + trigger [flags] + Manually fire an active deployed persona for testing. + The selector accepts agent id, compact agent id, + deployed name, persona slug, or persona id. Flags: + --workspace Workforce workspace; defaults to + the active workspace + --cloud-url Override the cloud base URL + --json Emit JSON + --no-prompt Fail instead of prompting for login harness check Probe which harnesses (${HARNESS_VALUES.join(', ')}) are installed and runnable on this machine. pick "" Pick the best-fit persona for a free-text task description @@ -4510,10 +4520,15 @@ export async function main(): Promise { return; } + if (subcommand === 'trigger') { + await runTrigger(rest); + return; + } + if (subcommand === 'deployments') { const [action, ...extra] = rest; if (!action || action === '-h' || action === '--help') { - process.stdout.write('Usage: agentworkforce deployments [flags]\n'); + process.stdout.write('Usage: agentworkforce deployments [flags]\n'); process.exit(action ? 0 : 1); } if (action === 'list') { @@ -4524,7 +4539,11 @@ export async function main(): Promise { await runDeploymentLogs(extra); return; } - die(`deployments: unknown action "${action}". Expected: list, logs`); + if (action === 'trigger') { + await runTrigger(extra); + return; + } + die(`deployments: unknown action "${action}". Expected: list, logs, trigger`); } if (subcommand === 'destroy') { diff --git a/packages/cli/src/trigger-command.test.ts b/packages/cli/src/trigger-command.test.ts new file mode 100644 index 00000000..abcb90ac --- /dev/null +++ b/packages/cli/src/trigger-command.test.ts @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildTriggerUrl, + formatTriggerResult, + parseTriggerArgs, + parseTriggerResponse +} from './trigger-command.js'; + +test('parseTriggerArgs accepts selector and cloud flags', () => { + assert.deepEqual( + parseTriggerArgs([ + 'hn-monitor', + '--workspace', + 'rw_123', + '--cloud-url=https://cloud.example.test', + '--json', + '--no-prompt' + ]), + { + selector: 'hn-monitor', + workspace: 'rw_123', + cloudUrl: 'https://cloud.example.test', + json: true, + noPrompt: true + } + ); +}); + +test('parseTriggerArgs supports help sentinel', () => { + assert.deepEqual(parseTriggerArgs(['--help']), { help: true }); +}); + +test('parseTriggerArgs rejects missing selector and extra positionals', () => { + assert.throws(() => parseTriggerArgs([]), /missing agent selector/); + assert.throws(() => parseTriggerArgs(['a', 'b']), /unexpected positional argument "b"/); +}); + +test('parseTriggerArgs rejects unknown single-dash flags as flags, not selectors', () => { + assert.throws(() => parseTriggerArgs(['-x']), /unknown flag "-x"/); +}); + +test('parseTriggerArgs does not swallow single-dash flags as option values', () => { + assert.throws(() => parseTriggerArgs(['hn-monitor', '--workspace', '-x']), /--workspace expects a value/); + assert.throws(() => parseTriggerArgs(['hn-monitor', '--cloud-url', '-x']), /--cloud-url expects a value/); +}); + +test('parseTriggerResponse validates cloud trigger response shape', () => { + assert.deepEqual( + parseTriggerResponse( + { + agentId: 'agent-1', + workspaceId: 'rw_123', + deploymentId: 'deployment-1', + status: 'starting' + }, + 'hn-monitor' + ), + { + agentId: 'agent-1', + workspaceId: 'rw_123', + deploymentId: 'deployment-1', + status: 'starting' + } + ); + + assert.throws( + () => parseTriggerResponse({ agentId: 'agent-1' }, 'hn-monitor'), + /incomplete response/ + ); +}); + +test('formatTriggerResult prints a concise human summary', () => { + assert.equal( + formatTriggerResult({ + agentId: 'agent-1', + workspaceId: 'rw_123', + deploymentId: 'deployment-1', + status: 'starting' + }), + 'triggered: agent-1\ndeployment: deployment-1\nworkspace: rw_123\nstatus: starting\n' + ); +}); + +test('buildTriggerUrl preserves cloud base paths', () => { + assert.equal( + buildTriggerUrl({ + cloudUrl: 'https://agentrelay.com/cloud', + workspace: 'rw_123', + agentId: 'agent-1' + }).toString(), + 'https://agentrelay.com/cloud/api/v1/workspaces/rw_123/deployments/agent-1/trigger' + ); +}); diff --git a/packages/cli/src/trigger-command.ts b/packages/cli/src/trigger-command.ts new file mode 100644 index 00000000..e8e13b92 --- /dev/null +++ b/packages/cli/src/trigger-command.ts @@ -0,0 +1,223 @@ +import { formatHttpErrorBody } from '@agentworkforce/deploy'; +import { + fetchDeployments, + resolveAgentSelector, + resolveDeploymentRequestContext +} from './list-command.js'; + +export const TRIGGER_USAGE = `usage: agentworkforce trigger [flags] + agentworkforce deployments trigger [flags] + +Manually fire an active deployed persona through the cloud trigger endpoint. +The selector may be an agent id, compact agent id, deployed name, persona slug, +or persona id. Use this to force a fresh run for testing without waiting for +the persona's normal schedule or integration event. + +Flags: + --workspace Workforce workspace; defaults to the active one. + --cloud-url Override the workforce cloud base URL. + --json Emit the trigger response JSON. + --no-prompt Fail instead of prompting for login. + -h, --help Print this message. +`; + +export interface TriggerOptions { + selector: string; + workspace?: string; + cloudUrl?: string; + json?: boolean; + noPrompt?: boolean; +} + +export type ParsedTriggerArgs = TriggerOptions | { help: true }; + +export interface TriggerResponse { + agentId: string; + workspaceId: string; + deploymentId: string; + status: string; +} + +export interface TriggerIO { + stdout: (text: string) => void; + stderr: (text: string) => void; +} + +const defaultIO: TriggerIO = { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text) +}; + +export function parseTriggerArgs(args: readonly string[]): ParsedTriggerArgs { + let selector: string | undefined; + let workspace: string | undefined; + let cloudUrl: string | undefined; + let json = false; + let noPrompt = false; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '-h' || arg === '--help') { + return { help: true }; + } else if (arg === '--workspace') { + workspace = expectValue('--workspace', args[++i]); + } else if (arg.startsWith('--workspace=')) { + workspace = expectInlineValue('--workspace', arg.slice('--workspace='.length)); + } else if (arg === '--cloud-url') { + cloudUrl = expectValue('--cloud-url', args[++i]); + } else if (arg.startsWith('--cloud-url=')) { + cloudUrl = expectInlineValue('--cloud-url', arg.slice('--cloud-url='.length)); + } else if (arg === '--json') { + json = true; + } else if (arg === '--no-prompt') { + noPrompt = true; + } else if (arg.startsWith('--')) { + throw new Error(`trigger: unknown flag "${arg}"`); + } else if (!arg.startsWith('-') && !selector) { + selector = arg; + } else if (arg.startsWith('-')) { + throw new Error(`trigger: unknown flag "${arg}"`); + } else { + throw new Error(`trigger: unexpected positional argument "${arg}"`); + } + } + + if (!selector) { + throw new Error('trigger: missing agent selector. Usage: agentworkforce trigger '); + } + + return { + selector, + ...(workspace ? { workspace } : {}), + ...(cloudUrl ? { cloudUrl } : {}), + ...(json ? { json: true } : {}), + ...(noPrompt ? { noPrompt: true } : {}) + }; +} + +export async function runTrigger( + args: readonly string[], + io: TriggerIO = defaultIO +): Promise { + let opts: ParsedTriggerArgs; + try { + opts = parseTriggerArgs(args); + } catch (err) { + io.stderr(`${err instanceof Error ? err.message : String(err)}\n\n${TRIGGER_USAGE}`); + process.exitCode = 1; + return; + } + if ('help' in opts) { + io.stdout(TRIGGER_USAGE); + return; + } + + try { + const result = await triggerDeployment(opts); + if (opts.json) { + io.stdout(`${JSON.stringify(result, null, 2)}\n`); + } else { + io.stdout(formatTriggerResult(result)); + } + process.exitCode = 0; + } catch (err) { + io.stderr(`trigger: ${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; + } +} + +export async function triggerDeployment(opts: TriggerOptions): Promise { + const ctx = await resolveDeploymentRequestContext({ + ...(opts.workspace ? { workspace: opts.workspace } : {}), + ...(opts.cloudUrl ? { cloudUrl: opts.cloudUrl } : {}), + ...(opts.noPrompt ? { noPrompt: true } : {}) + }); + + const agents = await fetchDeployments({ + cloudUrl: ctx.cloudUrl, + workspace: ctx.workspace, + token: ctx.token + }); + const agent = resolveAgentSelector(agents, opts.selector); + const url = buildTriggerUrl({ + cloudUrl: ctx.cloudUrl, + workspace: ctx.workspace, + agentId: agent.agentId + }); + + const res = await fetch(url, { + method: 'POST', + headers: { + authorization: `Bearer ${ctx.token}`, + 'user-agent': 'agentworkforce-cli/trigger' + } + }); + if (res.status === 401) { + throw new Error('unauthorized. Run `agentworkforce login` and retry.'); + } + if (!res.ok) { + const body = await res.text().catch(() => ''); + const hint = formatHttpErrorBody(body, { url: url.toString() }); + throw new Error(`manual trigger failed: ${res.status}${hint ? ` ${hint}` : ''}`); + } + + return parseTriggerResponse(await res.json(), opts.selector); +} + +export function formatTriggerResult(result: TriggerResponse): string { + return ( + `triggered: ${result.agentId}\n` + + `deployment: ${result.deploymentId}\n` + + `workspace: ${result.workspaceId}\n` + + `status: ${result.status}\n` + ); +} + +export function parseTriggerResponse(value: unknown, selector: string): TriggerResponse { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`manual trigger for "${selector}" returned an invalid response`); + } + const record = value as Record; + const agentId = readString(record, 'agentId'); + const workspaceId = readString(record, 'workspaceId'); + const deploymentId = readString(record, 'deploymentId'); + const status = readString(record, 'status'); + if (!agentId || !workspaceId || !deploymentId || !status) { + throw new Error(`manual trigger for "${selector}" returned an incomplete response`); + } + return { agentId, workspaceId, deploymentId, status }; +} + +export function buildTriggerUrl(input: { + cloudUrl: string; + workspace: string; + agentId: string; +}): URL { + return new URL( + `${trimTrailingSlash(input.cloudUrl)}/api/v1/workspaces/${encodeURIComponent(input.workspace)}` + + `/deployments/${encodeURIComponent(input.agentId)}/trigger` + ); +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function expectValue(flag: string, value: string | undefined): string { + if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) { + throw new Error(`trigger: ${flag} expects a value`); + } + return value; +} + +function expectInlineValue(flag: string, value: string): string { + if (!value.trim()) { + throw new Error(`trigger: ${flag} expects a value`); + } + return value; +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +}