Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/src/cli-impl.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <persona-path> [flags]
Deploy a persona as a managed agent. <persona-path> may
be prebuilt persona.json or authored persona.ts/js.
deploy <persona-id|persona-path> [flags]
Deploy a persona as a managed agent. A bare id resolves
through the registry cascade (including agents kept in
.agentworkforce/workforce/agents/<name>/); 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)
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,9 @@ import path from 'node:path';
import {
configureDeployCommandForTest,
formatDeployFailure,
looksLikeDeployPath,
parseDeployArgs,
resolveDeployPersonaSelector,
runLogin,
runLogout,
withDefaultDeployMode
Expand DownExpand Up@@ -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/<name>/` 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'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. resolveDeployPersonaSelector('persona-maker') resolves through the registry cascade, where a local persona named persona-maker under the runner's cwd (process.cwd()) or in the configurable persona dirs (default ~/.agentworkforce/workforce/personas) wins over the built-in catalog and returns a real file path, so the /no file to deploy/ assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.test.ts, line 548:
<comment>The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. `resolveDeployPersonaSelector('persona-maker')` resolves through the registry cascade, where a local persona named `persona-maker` under the runner's cwd (`process.cwd()`) or in the configurable persona dirs (default `~/.agentworkforce/workforce/personas`) wins over the built-in catalog and returns a real file path, so the `/no file to deploy/` assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.</comment>
<file context>
@@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
+ const trap = trapExit();
+ try {
+ assert.throws(
+ () => resolveDeployPersonaSelector('persona-maker'),
+ /__exit_trap__/
+ );
</file context>

/__exit_trap__/
);
} finally {
trap.restore();
}
assert.match(trap.stderr, /no file to deploy/);
});
60 changes: 56 additions & 4 deletions packages/cli/src/deploy-command.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ import {
setWorkspaceKey,
type StoredAuth
} from '@agent-relay/cloud';
import {
formatPersonaSourceLabel,
PersonaResolutionError,
resolvePersonaReference
} from '@agentworkforce/persona-registry';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
Expand DownExpand Up@@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial<DeployCommandDe
}

/**
* Argv parser + dispatcher for `agentworkforce deploy <persona-path> [flags]`.
* Argv parser + dispatcher for `agentworkforce deploy <persona-id|persona-path>`.
* 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.
*/
Expand DownExpand Up@@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise<void> {
}
}

const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-path> [flags]
const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-id|persona-path> [flags]

A bare id resolves through the registry cascade — including agents kept in
.agentworkforce/workforce/agents/<name>/. 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)
Expand DownExpand Up@@ -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/<name>/`
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid interactive validation for deploy selectors

When a valid handler-based deploy persona omits harness, model, or systemPrompt, this call fails before returning its file path because resolvePersonaReference unconditionally builds an interactive PersonaSelection, whose validator rejects those omissions. Deploy's persona parser explicitly permits these fields to be absent when onEvent is present, so the same persona deploys by path but cannot deploy by the newly supported bare ID; use a registry lookup that does not require the interactive projection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Resolve ID selectors without constructing an interactive PersonaSelection; otherwise handler personas that omit harness, model, or systemPrompt fail before deploy can obtain their path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 342:
<comment>Resolve ID selectors without constructing an interactive `PersonaSelection`; otherwise handler personas that omit `harness`, `model`, or `systemPrompt` fail before deploy can obtain their path.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+
+ let resolved;
+ try {
+ resolved = resolvePersonaReference(selector);
+ } catch (err) {
+ if (err instanceof PersonaResolutionError) {
</file context>

} 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deploy the resolved cascade rather than the overlay file

When the selected ID is defined by a partial registry overlay, returning its declaring path discards the merged resolved.spec that made the persona valid. Deploy subsequently rereads the raw file in compileAgentSource; for example, the new test fixture containing only id and extends fails with missing top-level "intent", and other inherited deploy fields are similarly lost. The ID path needs to deploy a materialized merged spec, while preserving the declaring directory for relative handler and asset paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a higher-priority partial overlay shadows an agent persona, resolved.path points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited cloud/onEvent, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 355:
<comment>When a higher-priority partial overlay shadows an agent persona, `resolved.path` points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited `cloud`/`onEvent`, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+ 'Pass a path to a persona.json or persona.ts instead.'
+ );
+ }
+ return resolved.path;
+}
+
</file context>

}

export function parseDeployArgs(args: readonly string[]): DeployOptions {
let personaPath: string | undefined;
let mode: DeployMode | undefined;
Expand DownExpand Up@@ -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 <persona-path>');
die('deploy: missing persona. Usage: agentworkforce deploy <persona-id|persona-path>');
}

return {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/src/cli-impl.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <persona-path> [flags]
Deploy a persona as a managed agent. <persona-path> may
be prebuilt persona.json or authored persona.ts/js.
deploy <persona-id|persona-path> [flags]
Deploy a persona as a managed agent. A bare id resolves
through the registry cascade (including agents kept in
.agentworkforce/workforce/agents/<name>/); 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)
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,9 @@ import path from 'node:path';
import {
configureDeployCommandForTest,
formatDeployFailure,
looksLikeDeployPath,
parseDeployArgs,
resolveDeployPersonaSelector,
runLogin,
runLogout,
withDefaultDeployMode
Expand DownExpand Up@@ -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/<name>/` 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'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. resolveDeployPersonaSelector('persona-maker') resolves through the registry cascade, where a local persona named persona-maker under the runner's cwd (process.cwd()) or in the configurable persona dirs (default ~/.agentworkforce/workforce/personas) wins over the built-in catalog and returns a real file path, so the /no file to deploy/ assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.test.ts, line 548:
<comment>The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. `resolveDeployPersonaSelector('persona-maker')` resolves through the registry cascade, where a local persona named `persona-maker` under the runner's cwd (`process.cwd()`) or in the configurable persona dirs (default `~/.agentworkforce/workforce/personas`) wins over the built-in catalog and returns a real file path, so the `/no file to deploy/` assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.</comment>
<file context>
@@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
+ const trap = trapExit();
+ try {
+ assert.throws(
+ () => resolveDeployPersonaSelector('persona-maker'),
+ /__exit_trap__/
+ );
</file context>

/__exit_trap__/
);
} finally {
trap.restore();
}
assert.match(trap.stderr, /no file to deploy/);
});
60 changes: 56 additions & 4 deletions packages/cli/src/deploy-command.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ import {
setWorkspaceKey,
type StoredAuth
} from '@agent-relay/cloud';
import {
formatPersonaSourceLabel,
PersonaResolutionError,
resolvePersonaReference
} from '@agentworkforce/persona-registry';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
Expand DownExpand Up@@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial<DeployCommandDe
}

/**
* Argv parser + dispatcher for `agentworkforce deploy <persona-path> [flags]`.
* Argv parser + dispatcher for `agentworkforce deploy <persona-id|persona-path>`.
* 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.
*/
Expand DownExpand Up@@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise<void> {
}
}

const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-path> [flags]
const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-id|persona-path> [flags]

A bare id resolves through the registry cascade — including agents kept in
.agentworkforce/workforce/agents/<name>/. 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)
Expand DownExpand Up@@ -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/<name>/`
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid interactive validation for deploy selectors

When a valid handler-based deploy persona omits harness, model, or systemPrompt, this call fails before returning its file path because resolvePersonaReference unconditionally builds an interactive PersonaSelection, whose validator rejects those omissions. Deploy's persona parser explicitly permits these fields to be absent when onEvent is present, so the same persona deploys by path but cannot deploy by the newly supported bare ID; use a registry lookup that does not require the interactive projection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Resolve ID selectors without constructing an interactive PersonaSelection; otherwise handler personas that omit harness, model, or systemPrompt fail before deploy can obtain their path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 342:
<comment>Resolve ID selectors without constructing an interactive `PersonaSelection`; otherwise handler personas that omit `harness`, `model`, or `systemPrompt` fail before deploy can obtain their path.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+
+ let resolved;
+ try {
+ resolved = resolvePersonaReference(selector);
+ } catch (err) {
+ if (err instanceof PersonaResolutionError) {
</file context>

} 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deploy the resolved cascade rather than the overlay file

When the selected ID is defined by a partial registry overlay, returning its declaring path discards the merged resolved.spec that made the persona valid. Deploy subsequently rereads the raw file in compileAgentSource; for example, the new test fixture containing only id and extends fails with missing top-level "intent", and other inherited deploy fields are similarly lost. The ID path needs to deploy a materialized merged spec, while preserving the declaring directory for relative handler and asset paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a higher-priority partial overlay shadows an agent persona, resolved.path points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited cloud/onEvent, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 355:
<comment>When a higher-priority partial overlay shadows an agent persona, `resolved.path` points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited `cloud`/`onEvent`, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+ 'Pass a path to a persona.json or persona.ts instead.'
+ );
+ }
+ return resolved.path;
+}
+
</file context>

}

export function parseDeployArgs(args: readonly string[]): DeployOptions {
let personaPath: string | undefined;
let mode: DeployMode | undefined;
Expand DownExpand Up@@ -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 <persona-path>');
die('deploy: missing persona. Usage: agentworkforce deploy <persona-id|persona-path>');
}

return {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/src/cli-impl.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <persona-path> [flags]
Deploy a persona as a managed agent. <persona-path> may
be prebuilt persona.json or authored persona.ts/js.
deploy <persona-id|persona-path> [flags]
Deploy a persona as a managed agent. A bare id resolves
through the registry cascade (including agents kept in
.agentworkforce/workforce/agents/<name>/); 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)
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,9 @@ import path from 'node:path';
import {
configureDeployCommandForTest,
formatDeployFailure,
looksLikeDeployPath,
parseDeployArgs,
resolveDeployPersonaSelector,
runLogin,
runLogout,
withDefaultDeployMode
Expand DownExpand Up@@ -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/<name>/` 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'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. resolveDeployPersonaSelector('persona-maker') resolves through the registry cascade, where a local persona named persona-maker under the runner's cwd (process.cwd()) or in the configurable persona dirs (default ~/.agentworkforce/workforce/personas) wins over the built-in catalog and returns a real file path, so the /no file to deploy/ assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.test.ts, line 548:
<comment>The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. `resolveDeployPersonaSelector('persona-maker')` resolves through the registry cascade, where a local persona named `persona-maker` under the runner's cwd (`process.cwd()`) or in the configurable persona dirs (default `~/.agentworkforce/workforce/personas`) wins over the built-in catalog and returns a real file path, so the `/no file to deploy/` assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.</comment>
<file context>
@@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
+ const trap = trapExit();
+ try {
+ assert.throws(
+ () => resolveDeployPersonaSelector('persona-maker'),
+ /__exit_trap__/
+ );
</file context>

/__exit_trap__/
);
} finally {
trap.restore();
}
assert.match(trap.stderr, /no file to deploy/);
});
60 changes: 56 additions & 4 deletions packages/cli/src/deploy-command.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ import {
setWorkspaceKey,
type StoredAuth
} from '@agent-relay/cloud';
import {
formatPersonaSourceLabel,
PersonaResolutionError,
resolvePersonaReference
} from '@agentworkforce/persona-registry';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
Expand DownExpand Up@@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial<DeployCommandDe
}

/**
* Argv parser + dispatcher for `agentworkforce deploy <persona-path> [flags]`.
* Argv parser + dispatcher for `agentworkforce deploy <persona-id|persona-path>`.
* 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.
*/
Expand DownExpand Up@@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise<void> {
}
}

const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-path> [flags]
const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-id|persona-path> [flags]

A bare id resolves through the registry cascade — including agents kept in
.agentworkforce/workforce/agents/<name>/. 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)
Expand DownExpand Up@@ -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/<name>/`
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid interactive validation for deploy selectors

When a valid handler-based deploy persona omits harness, model, or systemPrompt, this call fails before returning its file path because resolvePersonaReference unconditionally builds an interactive PersonaSelection, whose validator rejects those omissions. Deploy's persona parser explicitly permits these fields to be absent when onEvent is present, so the same persona deploys by path but cannot deploy by the newly supported bare ID; use a registry lookup that does not require the interactive projection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Resolve ID selectors without constructing an interactive PersonaSelection; otherwise handler personas that omit harness, model, or systemPrompt fail before deploy can obtain their path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 342:
<comment>Resolve ID selectors without constructing an interactive `PersonaSelection`; otherwise handler personas that omit `harness`, `model`, or `systemPrompt` fail before deploy can obtain their path.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+
+ let resolved;
+ try {
+ resolved = resolvePersonaReference(selector);
+ } catch (err) {
+ if (err instanceof PersonaResolutionError) {
</file context>

} 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deploy the resolved cascade rather than the overlay file

When the selected ID is defined by a partial registry overlay, returning its declaring path discards the merged resolved.spec that made the persona valid. Deploy subsequently rereads the raw file in compileAgentSource; for example, the new test fixture containing only id and extends fails with missing top-level "intent", and other inherited deploy fields are similarly lost. The ID path needs to deploy a materialized merged spec, while preserving the declaring directory for relative handler and asset paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a higher-priority partial overlay shadows an agent persona, resolved.path points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited cloud/onEvent, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 355:
<comment>When a higher-priority partial overlay shadows an agent persona, `resolved.path` points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited `cloud`/`onEvent`, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+ 'Pass a path to a persona.json or persona.ts instead.'
+ );
+ }
+ return resolved.path;
+}
+
</file context>

}

export function parseDeployArgs(args: readonly string[]): DeployOptions {
let personaPath: string | undefined;
let mode: DeployMode | undefined;
Expand DownExpand Up@@ -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 <persona-path>');
die('deploy: missing persona. Usage: agentworkforce deploy <persona-id|persona-path>');
}

return {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/src/cli-impl.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <persona-path> [flags]
Deploy a persona as a managed agent. <persona-path> may
be prebuilt persona.json or authored persona.ts/js.
deploy <persona-id|persona-path> [flags]
Deploy a persona as a managed agent. A bare id resolves
through the registry cascade (including agents kept in
.agentworkforce/workforce/agents/<name>/); 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)
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,9 @@ import path from 'node:path';
import {
configureDeployCommandForTest,
formatDeployFailure,
looksLikeDeployPath,
parseDeployArgs,
resolveDeployPersonaSelector,
runLogin,
runLogout,
withDefaultDeployMode
Expand DownExpand Up@@ -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/<name>/` 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'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. resolveDeployPersonaSelector('persona-maker') resolves through the registry cascade, where a local persona named persona-maker under the runner's cwd (process.cwd()) or in the configurable persona dirs (default ~/.agentworkforce/workforce/personas) wins over the built-in catalog and returns a real file path, so the /no file to deploy/ assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.test.ts, line 548:
<comment>The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. `resolveDeployPersonaSelector('persona-maker')` resolves through the registry cascade, where a local persona named `persona-maker` under the runner's cwd (`process.cwd()`) or in the configurable persona dirs (default `~/.agentworkforce/workforce/personas`) wins over the built-in catalog and returns a real file path, so the `/no file to deploy/` assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.</comment>
<file context>
@@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
+ const trap = trapExit();
+ try {
+ assert.throws(
+ () => resolveDeployPersonaSelector('persona-maker'),
+ /__exit_trap__/
+ );
</file context>

/__exit_trap__/
);
} finally {
trap.restore();
}
assert.match(trap.stderr, /no file to deploy/);
});
60 changes: 56 additions & 4 deletions packages/cli/src/deploy-command.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ import {
setWorkspaceKey,
type StoredAuth
} from '@agent-relay/cloud';
import {
formatPersonaSourceLabel,
PersonaResolutionError,
resolvePersonaReference
} from '@agentworkforce/persona-registry';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
Expand DownExpand Up@@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial<DeployCommandDe
}

/**
* Argv parser + dispatcher for `agentworkforce deploy <persona-path> [flags]`.
* Argv parser + dispatcher for `agentworkforce deploy <persona-id|persona-path>`.
* 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.
*/
Expand DownExpand Up@@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise<void> {
}
}

const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-path> [flags]
const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-id|persona-path> [flags]

A bare id resolves through the registry cascade — including agents kept in
.agentworkforce/workforce/agents/<name>/. 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)
Expand DownExpand Up@@ -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/<name>/`
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid interactive validation for deploy selectors

When a valid handler-based deploy persona omits harness, model, or systemPrompt, this call fails before returning its file path because resolvePersonaReference unconditionally builds an interactive PersonaSelection, whose validator rejects those omissions. Deploy's persona parser explicitly permits these fields to be absent when onEvent is present, so the same persona deploys by path but cannot deploy by the newly supported bare ID; use a registry lookup that does not require the interactive projection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Resolve ID selectors without constructing an interactive PersonaSelection; otherwise handler personas that omit harness, model, or systemPrompt fail before deploy can obtain their path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 342:
<comment>Resolve ID selectors without constructing an interactive `PersonaSelection`; otherwise handler personas that omit `harness`, `model`, or `systemPrompt` fail before deploy can obtain their path.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+
+ let resolved;
+ try {
+ resolved = resolvePersonaReference(selector);
+ } catch (err) {
+ if (err instanceof PersonaResolutionError) {
</file context>

} 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deploy the resolved cascade rather than the overlay file

When the selected ID is defined by a partial registry overlay, returning its declaring path discards the merged resolved.spec that made the persona valid. Deploy subsequently rereads the raw file in compileAgentSource; for example, the new test fixture containing only id and extends fails with missing top-level "intent", and other inherited deploy fields are similarly lost. The ID path needs to deploy a materialized merged spec, while preserving the declaring directory for relative handler and asset paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a higher-priority partial overlay shadows an agent persona, resolved.path points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited cloud/onEvent, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 355:
<comment>When a higher-priority partial overlay shadows an agent persona, `resolved.path` points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited `cloud`/`onEvent`, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+ 'Pass a path to a persona.json or persona.ts instead.'
+ );
+ }
+ return resolved.path;
+}
+
</file context>

}

export function parseDeployArgs(args: readonly string[]): DeployOptions {
let personaPath: string | undefined;
let mode: DeployMode | undefined;
Expand DownExpand Up@@ -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 <persona-path>');
die('deploy: missing persona. Usage: agentworkforce deploy <persona-id|persona-path>');
}

return {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/src/cli-impl.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <persona-path> [flags]
Deploy a persona as a managed agent. <persona-path> may
be prebuilt persona.json or authored persona.ts/js.
deploy <persona-id|persona-path> [flags]
Deploy a persona as a managed agent. A bare id resolves
through the registry cascade (including agents kept in
.agentworkforce/workforce/agents/<name>/); 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)
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,9 @@ import path from 'node:path';
import {
configureDeployCommandForTest,
formatDeployFailure,
looksLikeDeployPath,
parseDeployArgs,
resolveDeployPersonaSelector,
runLogin,
runLogout,
withDefaultDeployMode
Expand DownExpand Up@@ -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/<name>/` 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'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. resolveDeployPersonaSelector('persona-maker') resolves through the registry cascade, where a local persona named persona-maker under the runner's cwd (process.cwd()) or in the configurable persona dirs (default ~/.agentworkforce/workforce/personas) wins over the built-in catalog and returns a real file path, so the /no file to deploy/ assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.test.ts, line 548:
<comment>The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. `resolveDeployPersonaSelector('persona-maker')` resolves through the registry cascade, where a local persona named `persona-maker` under the runner's cwd (`process.cwd()`) or in the configurable persona dirs (default `~/.agentworkforce/workforce/personas`) wins over the built-in catalog and returns a real file path, so the `/no file to deploy/` assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.</comment>
<file context>
@@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
+ const trap = trapExit();
+ try {
+ assert.throws(
+ () => resolveDeployPersonaSelector('persona-maker'),
+ /__exit_trap__/
+ );
</file context>

/__exit_trap__/
);
} finally {
trap.restore();
}
assert.match(trap.stderr, /no file to deploy/);
});
60 changes: 56 additions & 4 deletions packages/cli/src/deploy-command.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ import {
setWorkspaceKey,
type StoredAuth
} from '@agent-relay/cloud';
import {
formatPersonaSourceLabel,
PersonaResolutionError,
resolvePersonaReference
} from '@agentworkforce/persona-registry';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
Expand DownExpand Up@@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial<DeployCommandDe
}

/**
* Argv parser + dispatcher for `agentworkforce deploy <persona-path> [flags]`.
* Argv parser + dispatcher for `agentworkforce deploy <persona-id|persona-path>`.
* 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.
*/
Expand DownExpand Up@@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise<void> {
}
}

const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-path> [flags]
const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-id|persona-path> [flags]

A bare id resolves through the registry cascade — including agents kept in
.agentworkforce/workforce/agents/<name>/. 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)
Expand DownExpand Up@@ -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/<name>/`
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid interactive validation for deploy selectors

When a valid handler-based deploy persona omits harness, model, or systemPrompt, this call fails before returning its file path because resolvePersonaReference unconditionally builds an interactive PersonaSelection, whose validator rejects those omissions. Deploy's persona parser explicitly permits these fields to be absent when onEvent is present, so the same persona deploys by path but cannot deploy by the newly supported bare ID; use a registry lookup that does not require the interactive projection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Resolve ID selectors without constructing an interactive PersonaSelection; otherwise handler personas that omit harness, model, or systemPrompt fail before deploy can obtain their path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 342:
<comment>Resolve ID selectors without constructing an interactive `PersonaSelection`; otherwise handler personas that omit `harness`, `model`, or `systemPrompt` fail before deploy can obtain their path.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+
+ let resolved;
+ try {
+ resolved = resolvePersonaReference(selector);
+ } catch (err) {
+ if (err instanceof PersonaResolutionError) {
</file context>

} 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deploy the resolved cascade rather than the overlay file

When the selected ID is defined by a partial registry overlay, returning its declaring path discards the merged resolved.spec that made the persona valid. Deploy subsequently rereads the raw file in compileAgentSource; for example, the new test fixture containing only id and extends fails with missing top-level "intent", and other inherited deploy fields are similarly lost. The ID path needs to deploy a materialized merged spec, while preserving the declaring directory for relative handler and asset paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a higher-priority partial overlay shadows an agent persona, resolved.path points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited cloud/onEvent, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 355:
<comment>When a higher-priority partial overlay shadows an agent persona, `resolved.path` points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited `cloud`/`onEvent`, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+ 'Pass a path to a persona.json or persona.ts instead.'
+ );
+ }
+ return resolved.path;
+}
+
</file context>

}

export function parseDeployArgs(args: readonly string[]): DeployOptions {
let personaPath: string | undefined;
let mode: DeployMode | undefined;
Expand DownExpand Up@@ -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 <persona-path>');
die('deploy: missing persona. Usage: agentworkforce deploy <persona-id|persona-path>');
}

return {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/src/cli-impl.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <persona-path> [flags]
Deploy a persona as a managed agent. <persona-path> may
be prebuilt persona.json or authored persona.ts/js.
deploy <persona-id|persona-path> [flags]
Deploy a persona as a managed agent. A bare id resolves
through the registry cascade (including agents kept in
.agentworkforce/workforce/agents/<name>/); 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)
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,9 @@ import path from 'node:path';
import {
configureDeployCommandForTest,
formatDeployFailure,
looksLikeDeployPath,
parseDeployArgs,
resolveDeployPersonaSelector,
runLogin,
runLogout,
withDefaultDeployMode
Expand DownExpand Up@@ -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/<name>/` 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'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. resolveDeployPersonaSelector('persona-maker') resolves through the registry cascade, where a local persona named persona-maker under the runner's cwd (process.cwd()) or in the configurable persona dirs (default ~/.agentworkforce/workforce/personas) wins over the built-in catalog and returns a real file path, so the /no file to deploy/ assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.test.ts, line 548:
<comment>The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. `resolveDeployPersonaSelector('persona-maker')` resolves through the registry cascade, where a local persona named `persona-maker` under the runner's cwd (`process.cwd()`) or in the configurable persona dirs (default `~/.agentworkforce/workforce/personas`) wins over the built-in catalog and returns a real file path, so the `/no file to deploy/` assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.</comment>
<file context>
@@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
+ const trap = trapExit();
+ try {
+ assert.throws(
+ () => resolveDeployPersonaSelector('persona-maker'),
+ /__exit_trap__/
+ );
</file context>

/__exit_trap__/
);
} finally {
trap.restore();
}
assert.match(trap.stderr, /no file to deploy/);
});
60 changes: 56 additions & 4 deletions packages/cli/src/deploy-command.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ import {
setWorkspaceKey,
type StoredAuth
} from '@agent-relay/cloud';
import {
formatPersonaSourceLabel,
PersonaResolutionError,
resolvePersonaReference
} from '@agentworkforce/persona-registry';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
Expand DownExpand Up@@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial<DeployCommandDe
}

/**
* Argv parser + dispatcher for `agentworkforce deploy <persona-path> [flags]`.
* Argv parser + dispatcher for `agentworkforce deploy <persona-id|persona-path>`.
* 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.
*/
Expand DownExpand Up@@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise<void> {
}
}

const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-path> [flags]
const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-id|persona-path> [flags]

A bare id resolves through the registry cascade — including agents kept in
.agentworkforce/workforce/agents/<name>/. 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)
Expand DownExpand Up@@ -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/<name>/`
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid interactive validation for deploy selectors

When a valid handler-based deploy persona omits harness, model, or systemPrompt, this call fails before returning its file path because resolvePersonaReference unconditionally builds an interactive PersonaSelection, whose validator rejects those omissions. Deploy's persona parser explicitly permits these fields to be absent when onEvent is present, so the same persona deploys by path but cannot deploy by the newly supported bare ID; use a registry lookup that does not require the interactive projection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Resolve ID selectors without constructing an interactive PersonaSelection; otherwise handler personas that omit harness, model, or systemPrompt fail before deploy can obtain their path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 342:
<comment>Resolve ID selectors without constructing an interactive `PersonaSelection`; otherwise handler personas that omit `harness`, `model`, or `systemPrompt` fail before deploy can obtain their path.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+
+ let resolved;
+ try {
+ resolved = resolvePersonaReference(selector);
+ } catch (err) {
+ if (err instanceof PersonaResolutionError) {
</file context>

} 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deploy the resolved cascade rather than the overlay file

When the selected ID is defined by a partial registry overlay, returning its declaring path discards the merged resolved.spec that made the persona valid. Deploy subsequently rereads the raw file in compileAgentSource; for example, the new test fixture containing only id and extends fails with missing top-level "intent", and other inherited deploy fields are similarly lost. The ID path needs to deploy a materialized merged spec, while preserving the declaring directory for relative handler and asset paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a higher-priority partial overlay shadows an agent persona, resolved.path points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited cloud/onEvent, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 355:
<comment>When a higher-priority partial overlay shadows an agent persona, `resolved.path` points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited `cloud`/`onEvent`, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+ 'Pass a path to a persona.json or persona.ts instead.'
+ );
+ }
+ return resolved.path;
+}
+
</file context>

}

export function parseDeployArgs(args: readonly string[]): DeployOptions {
let personaPath: string | undefined;
let mode: DeployMode | undefined;
Expand DownExpand Up@@ -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 <persona-path>');
die('deploy: missing persona. Usage: agentworkforce deploy <persona-id|persona-path>');
}

return {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/src/cli-impl.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <persona-path> [flags]
Deploy a persona as a managed agent. <persona-path> may
be prebuilt persona.json or authored persona.ts/js.
deploy <persona-id|persona-path> [flags]
Deploy a persona as a managed agent. A bare id resolves
through the registry cascade (including agents kept in
.agentworkforce/workforce/agents/<name>/); 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)
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,9 @@ import path from 'node:path';
import {
configureDeployCommandForTest,
formatDeployFailure,
looksLikeDeployPath,
parseDeployArgs,
resolveDeployPersonaSelector,
runLogin,
runLogout,
withDefaultDeployMode
Expand DownExpand Up@@ -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/<name>/` 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'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. resolveDeployPersonaSelector('persona-maker') resolves through the registry cascade, where a local persona named persona-maker under the runner's cwd (process.cwd()) or in the configurable persona dirs (default ~/.agentworkforce/workforce/personas) wins over the built-in catalog and returns a real file path, so the /no file to deploy/ assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.test.ts, line 548:
<comment>The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. `resolveDeployPersonaSelector('persona-maker')` resolves through the registry cascade, where a local persona named `persona-maker` under the runner's cwd (`process.cwd()`) or in the configurable persona dirs (default `~/.agentworkforce/workforce/personas`) wins over the built-in catalog and returns a real file path, so the `/no file to deploy/` assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.</comment>
<file context>
@@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
+ const trap = trapExit();
+ try {
+ assert.throws(
+ () => resolveDeployPersonaSelector('persona-maker'),
+ /__exit_trap__/
+ );
</file context>

/__exit_trap__/
);
} finally {
trap.restore();
}
assert.match(trap.stderr, /no file to deploy/);
});
60 changes: 56 additions & 4 deletions packages/cli/src/deploy-command.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ import {
setWorkspaceKey,
type StoredAuth
} from '@agent-relay/cloud';
import {
formatPersonaSourceLabel,
PersonaResolutionError,
resolvePersonaReference
} from '@agentworkforce/persona-registry';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
Expand DownExpand Up@@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial<DeployCommandDe
}

/**
* Argv parser + dispatcher for `agentworkforce deploy <persona-path> [flags]`.
* Argv parser + dispatcher for `agentworkforce deploy <persona-id|persona-path>`.
* 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.
*/
Expand DownExpand Up@@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise<void> {
}
}

const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-path> [flags]
const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-id|persona-path> [flags]

A bare id resolves through the registry cascade — including agents kept in
.agentworkforce/workforce/agents/<name>/. 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)
Expand DownExpand Up@@ -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/<name>/`
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid interactive validation for deploy selectors

When a valid handler-based deploy persona omits harness, model, or systemPrompt, this call fails before returning its file path because resolvePersonaReference unconditionally builds an interactive PersonaSelection, whose validator rejects those omissions. Deploy's persona parser explicitly permits these fields to be absent when onEvent is present, so the same persona deploys by path but cannot deploy by the newly supported bare ID; use a registry lookup that does not require the interactive projection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Resolve ID selectors without constructing an interactive PersonaSelection; otherwise handler personas that omit harness, model, or systemPrompt fail before deploy can obtain their path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 342:
<comment>Resolve ID selectors without constructing an interactive `PersonaSelection`; otherwise handler personas that omit `harness`, `model`, or `systemPrompt` fail before deploy can obtain their path.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+
+ let resolved;
+ try {
+ resolved = resolvePersonaReference(selector);
+ } catch (err) {
+ if (err instanceof PersonaResolutionError) {
</file context>

} 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deploy the resolved cascade rather than the overlay file

When the selected ID is defined by a partial registry overlay, returning its declaring path discards the merged resolved.spec that made the persona valid. Deploy subsequently rereads the raw file in compileAgentSource; for example, the new test fixture containing only id and extends fails with missing top-level "intent", and other inherited deploy fields are similarly lost. The ID path needs to deploy a materialized merged spec, while preserving the declaring directory for relative handler and asset paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a higher-priority partial overlay shadows an agent persona, resolved.path points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited cloud/onEvent, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 355:
<comment>When a higher-priority partial overlay shadows an agent persona, `resolved.path` points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited `cloud`/`onEvent`, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+ 'Pass a path to a persona.json or persona.ts instead.'
+ );
+ }
+ return resolved.path;
+}
+
</file context>

}

export function parseDeployArgs(args: readonly string[]): DeployOptions {
let personaPath: string | undefined;
let mode: DeployMode | undefined;
Expand DownExpand Up@@ -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 <persona-path>');
die('deploy: missing persona. Usage: agentworkforce deploy <persona-id|persona-path>');
}

return {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/src/cli-impl.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <persona-path> [flags]
Deploy a persona as a managed agent. <persona-path> may
be prebuilt persona.json or authored persona.ts/js.
deploy <persona-id|persona-path> [flags]
Deploy a persona as a managed agent. A bare id resolves
through the registry cascade (including agents kept in
.agentworkforce/workforce/agents/<name>/); 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)
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,9 @@ import path from 'node:path';
import {
configureDeployCommandForTest,
formatDeployFailure,
looksLikeDeployPath,
parseDeployArgs,
resolveDeployPersonaSelector,
runLogin,
runLogout,
withDefaultDeployMode
Expand DownExpand Up@@ -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/<name>/` 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'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. resolveDeployPersonaSelector('persona-maker') resolves through the registry cascade, where a local persona named persona-maker under the runner's cwd (process.cwd()) or in the configurable persona dirs (default ~/.agentworkforce/workforce/personas) wins over the built-in catalog and returns a real file path, so the /no file to deploy/ assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.test.ts, line 548:
<comment>The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. `resolveDeployPersonaSelector('persona-maker')` resolves through the registry cascade, where a local persona named `persona-maker` under the runner's cwd (`process.cwd()`) or in the configurable persona dirs (default `~/.agentworkforce/workforce/personas`) wins over the built-in catalog and returns a real file path, so the `/no file to deploy/` assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.</comment>
<file context>
@@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
+ const trap = trapExit();
+ try {
+ assert.throws(
+ () => resolveDeployPersonaSelector('persona-maker'),
+ /__exit_trap__/
+ );
</file context>

/__exit_trap__/
);
} finally {
trap.restore();
}
assert.match(trap.stderr, /no file to deploy/);
});
60 changes: 56 additions & 4 deletions packages/cli/src/deploy-command.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ import {
setWorkspaceKey,
type StoredAuth
} from '@agent-relay/cloud';
import {
formatPersonaSourceLabel,
PersonaResolutionError,
resolvePersonaReference
} from '@agentworkforce/persona-registry';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
Expand DownExpand Up@@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial<DeployCommandDe
}

/**
* Argv parser + dispatcher for `agentworkforce deploy <persona-path> [flags]`.
* Argv parser + dispatcher for `agentworkforce deploy <persona-id|persona-path>`.
* 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.
*/
Expand DownExpand Up@@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise<void> {
}
}

const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-path> [flags]
const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-id|persona-path> [flags]

A bare id resolves through the registry cascade — including agents kept in
.agentworkforce/workforce/agents/<name>/. 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)
Expand DownExpand Up@@ -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/<name>/`
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid interactive validation for deploy selectors

When a valid handler-based deploy persona omits harness, model, or systemPrompt, this call fails before returning its file path because resolvePersonaReference unconditionally builds an interactive PersonaSelection, whose validator rejects those omissions. Deploy's persona parser explicitly permits these fields to be absent when onEvent is present, so the same persona deploys by path but cannot deploy by the newly supported bare ID; use a registry lookup that does not require the interactive projection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Resolve ID selectors without constructing an interactive PersonaSelection; otherwise handler personas that omit harness, model, or systemPrompt fail before deploy can obtain their path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 342:
<comment>Resolve ID selectors without constructing an interactive `PersonaSelection`; otherwise handler personas that omit `harness`, `model`, or `systemPrompt` fail before deploy can obtain their path.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+
+ let resolved;
+ try {
+ resolved = resolvePersonaReference(selector);
+ } catch (err) {
+ if (err instanceof PersonaResolutionError) {
</file context>

} 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deploy the resolved cascade rather than the overlay file

When the selected ID is defined by a partial registry overlay, returning its declaring path discards the merged resolved.spec that made the persona valid. Deploy subsequently rereads the raw file in compileAgentSource; for example, the new test fixture containing only id and extends fails with missing top-level "intent", and other inherited deploy fields are similarly lost. The ID path needs to deploy a materialized merged spec, while preserving the declaring directory for relative handler and asset paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a higher-priority partial overlay shadows an agent persona, resolved.path points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited cloud/onEvent, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 355:
<comment>When a higher-priority partial overlay shadows an agent persona, `resolved.path` points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited `cloud`/`onEvent`, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.</comment>
<file context>
@@ -303,6 +312,49 @@ Flags:
+ 'Pass a path to a persona.json or persona.ts instead.'
+ );
+ }
+ return resolved.path;
+}
+
</file context>

}

export function parseDeployArgs(args: readonly string[]): DeployOptions {
let personaPath: string | undefined;
let mode: DeployMode | undefined;
Expand DownExpand Up@@ -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 <persona-path>');
die('deploy: missing persona. Usage: agentworkforce deploy <persona-id|persona-path>');
}

return {
Expand Down
Loading