diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index 9dfa5014..0df76610 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -299,9 +299,11 @@ Commands: persona-maker with the task as input, or exits non-zero (non-TTY) with a hint. Exit codes: 0 match, 2 no match, 3 picker unavailable. - deploy [flags] - Deploy a persona as a managed agent. may - be prebuilt persona.json or authored persona.ts/js. + deploy [flags] + Deploy a persona as a managed agent. A bare id resolves + through the registry cascade (including agents kept in + .agentworkforce/workforce/agents//); a path may be + prebuilt persona.json or authored persona.ts/js. Modes: --mode dev run the persona locally (default if no Daytona/workspace creds resolve) diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts index 09105749..17402d39 100644 --- a/packages/cli/src/deploy-command.test.ts +++ b/packages/cli/src/deploy-command.test.ts @@ -4,7 +4,9 @@ import path from 'node:path'; import { configureDeployCommandForTest, formatDeployFailure, + looksLikeDeployPath, parseDeployArgs, + resolveDeployPersonaSelector, runLogin, runLogout, withDefaultDeployMode @@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the restoreDeps(); } }); + +// --- persona selector ------------------------------------------------------- +// `deploy` takes a persona id as well as a path, so an agent living in +// `.agentworkforce/workforce/agents//` deploys by name. + +test('looksLikeDeployPath: path syntax and persona extensions are paths', () => { + for (const selector of [ + './persona.json', + '../agents/x/persona.ts', + '/tmp/review/persona.ts', + '~/personas/thing.json', + 'agents/proposal-agent/persona.json', + 'persona.json', + 'persona.ts' + ]) { + assert.equal(looksLikeDeployPath(selector), true, selector); + } +}); + +test('looksLikeDeployPath: a bare id is not a path', () => { + for (const selector of ['proposal-agent', 'customer-dev', 'persona-maker']) { + assert.equal(looksLikeDeployPath(selector), false, selector); + } +}); + +test('resolveDeployPersonaSelector: a path resolves without touching the registry', () => { + assert.equal( + resolveDeployPersonaSelector('./persona.json'), + path.resolve('./persona.json') + ); +}); + +test('resolveDeployPersonaSelector: an id resolves to the declaring file', async () => { + const { mkdtempSync, mkdirSync, rmSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + + const root = mkdtempSync(join(tmpdir(), 'aw-deploy-selector-')); + const agentDir = join(root, '.agentworkforce', 'workforce', 'agents', 'proposal-agent'); + mkdirSync(agentDir, { recursive: true }); + writeFileSync( + join(agentDir, 'persona.json'), + JSON.stringify({ id: 'proposal-agent', extends: 'persona-maker' }) + ); + const cwd = process.cwd(); + try { + process.chdir(root); + // realpath: macOS tmpdirs are symlinks, and the registry resolves through them. + const { realpathSync } = await import('node:fs'); + assert.equal( + realpathSync(resolveDeployPersonaSelector('proposal-agent')), + realpathSync(join(agentDir, 'persona.json')) + ); + } finally { + process.chdir(cwd); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('resolveDeployPersonaSelector: a built-in id explains it has no file', () => { + const trap = trapExit(); + try { + assert.throws( + () => resolveDeployPersonaSelector('persona-maker'), + /__exit_trap__/ + ); + } finally { + trap.restore(); + } + assert.match(trap.stderr, /no file to deploy/); +}); diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index e05f0629..0c6c7d7d 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -9,6 +9,11 @@ import { setWorkspaceKey, type StoredAuth } from '@agent-relay/cloud'; +import { + formatPersonaSourceLabel, + PersonaResolutionError, + resolvePersonaReference +} from '@agentworkforce/persona-registry'; import { canonicalizeCloudUrl, clearActiveWorkspace, @@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial [flags]`. + * Argv parser + dispatcher for `agentworkforce deploy `. * Keeps cli.ts itself slim — the file is already a large dispatcher and * each command lands in its own module when it grows past trivial. */ @@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise { } } -const DEPLOY_USAGE = `usage: agentworkforce deploy [flags] +const DEPLOY_USAGE = `usage: agentworkforce deploy [flags] + +A bare id resolves through the registry cascade — including agents kept in +.agentworkforce/workforce/agents//. A path may be a prebuilt persona.json +or an authored persona.ts/js. Flags: --mode dev|sandbox|cloud Pick a run mode (prompts in an interactive terminal) @@ -303,6 +312,49 @@ Flags: const ON_EXISTS_CHOICES = ['update', 'destroy', 'cancel'] as const; +/** + * A selector is a path when it carries path syntax or a persona-source + * extension. Anything else is a persona id looked up through the registry + * cascade, so an agent that lives in `.agentworkforce/workforce/agents//` + * deploys by name from anywhere in the repo. + * + * Syntax decides, not the filesystem: a bare `proposal-agent` that happens to + * match a directory in cwd must still mean the persona, or the same command + * would deploy different things depending on where it ran. + */ +export function looksLikeDeployPath(selector: string): boolean { + return ( + selector.startsWith('.') || + selector.startsWith('/') || + selector.startsWith('~') || + selector.includes(path.sep) || + selector.includes('/') || + isPersonaSourcePath(selector) || + selector.toLowerCase().endsWith('.json') + ); +} + +export function resolveDeployPersonaSelector(selector: string): string { + if (looksLikeDeployPath(selector)) return path.resolve(selector); + + let resolved; + try { + resolved = resolvePersonaReference(selector); + } catch (err) { + if (err instanceof PersonaResolutionError) { + die(`deploy: ${err.message}`); + } + throw err; + } + if (!resolved.path) { + die( + `deploy: persona "${selector}" resolves to the ${formatPersonaSourceLabel(resolved.source)} catalog, which has no file to deploy. ` + + 'Pass a path to a persona.json or persona.ts instead.' + ); + } + return resolved.path; +} + export function parseDeployArgs(args: readonly string[]): DeployOptions { let personaPath: string | undefined; let mode: DeployMode | undefined; @@ -373,14 +425,14 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { } else if (a.startsWith('--')) { die(`deploy: unknown flag "${a}"`); } else if (!personaPath) { - personaPath = path.resolve(a); + personaPath = resolveDeployPersonaSelector(a); } else { die(`deploy: unexpected positional argument "${a}"`); } } if (!personaPath) { - die('deploy: missing persona path. Usage: agentworkforce deploy '); + die('deploy: missing persona. Usage: agentworkforce deploy '); } return {