feat(cli): add agent update command for name and labels

This commit is contained in:
Mohamed Boudra
2026-02-12 14:15:33 +07:00
parent f55d0cd25f
commit fba7c73c8b
8 changed files with 498 additions and 0 deletions
+16
View File
@@ -16,6 +16,7 @@ import { runSendCommand } from './commands/agent/send.js'
import { runInspectCommand } from './commands/agent/inspect.js'
import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js'
import { runUpdateCommand } from './commands/agent/update.js'
import { withOutput } from './output/index.js'
const VERSION = '0.1.0'
@@ -125,6 +126,21 @@ export function createCli(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand))
program
.command('update')
.description('Update an agent (alias for "paseo agent update")')
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
collectMultiple,
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runUpdateCommand))
// Top-level local daemon shortcuts
program.addCommand(daemonStartCommand())
+16
View File
@@ -9,6 +9,7 @@ import { runSendCommand } from './send.js'
import { runInspectCommand } from './inspect.js'
import { runWaitCommand } from './wait.js'
import { runAttachCommand } from './attach.js'
import { runUpdateCommand } from './update.js'
import { withOutput } from '../../output/index.js'
export function createAgentCommand(): Command {
@@ -120,5 +121,20 @@ export function createAgentCommand(): Command {
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand))
agent
.command('update')
.description("Update an agent's metadata")
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
collectMultiple,
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runUpdateCommand))
return agent
}
+177
View File
@@ -0,0 +1,177 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
/** Result type for agent update command */
export interface AgentUpdateResult {
agentId: string
name: string | null
labels: string
}
/** Schema for update command output */
export const updateSchema: OutputSchema<AgentUpdateResult> = {
idField: 'agentId',
columns: [
{ header: 'AGENT ID', field: 'agentId' },
{ header: 'NAME', field: 'name' },
{ header: 'LABELS', field: 'labels' },
],
}
export interface AgentUpdateOptions extends CommandOptions {
name?: string
label?: string[]
host?: string
}
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
const parsed: Record<string, string> = {}
if (!labels) {
return parsed
}
for (const rawLabel of labels) {
for (const segment of rawLabel.split(',')) {
const label = segment.trim()
if (!label) {
continue
}
const eqIndex = label.indexOf('=')
if (eqIndex === -1) {
const error: CommandError = {
code: 'INVALID_LABEL',
message: `Invalid label format: ${label}`,
details: 'Labels must be in key=value format',
}
throw error
}
const key = label.slice(0, eqIndex).trim()
const value = label.slice(eqIndex + 1)
if (!key) {
const error: CommandError = {
code: 'INVALID_LABEL',
message: `Invalid label format: ${label}`,
details: 'Labels must include a non-empty key in key=value format',
}
throw error
}
parsed[key] = value
}
}
return parsed
}
function formatLabels(labels: Record<string, string>): string {
const entries = Object.entries(labels)
if (entries.length === 0) {
return '-'
}
return entries.map(([key, value]) => `${key}=${value}`).join(',')
}
export async function runUpdateCommand(
agentIdArg: string,
options: AgentUpdateOptions,
_command: Command
): Promise<AgentUpdateCommandResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent update <id> [--name <name>] [--label <key=value>]',
}
throw error
}
const name = options.name?.trim()
if (options.name !== undefined && !name) {
const error: CommandError = {
code: 'INVALID_NAME',
message: 'Name cannot be empty',
details: 'Use --name <name> with a non-empty value',
}
throw error
}
const labels = parseLabelOptions(options.label)
if (!name && Object.keys(labels).length === 0) {
const error: CommandError = {
code: 'NO_CHANGES_PROVIDED',
message: 'Nothing to update',
details: 'Provide at least one of: --name <name>, --label <key=value>',
}
throw error
}
let client
try {
client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
}
try {
const agent = await client.fetchAgent(agentIdArg)
if (!agent) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
}
const agentId = agent.id
await client.updateAgent(agentId, {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
})
const updated = await client.fetchAgent(agentId)
if (!updated) {
throw new Error(`Agent not found after update: ${agentId}`)
}
await client.close()
return {
type: 'single',
data: {
agentId,
name: updated.title,
labels: formatLabels(updated.labels),
},
schema: updateSchema,
}
} catch (err) {
await client.close().catch(() => {})
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
const error: CommandError = {
code: 'UPDATE_FAILED',
message: `Failed to update agent: ${message}`,
}
throw error
}
}
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env npx tsx
/**
* Phase 16: Agent Update Command Tests
*
* Tests the agent update command for metadata updates.
* Since daemon may not be running, we test:
* - Help and argument parsing
* - Validation for required update fields
* - Graceful daemon connection errors
* - Top-level alias support (`paseo update`)
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
$.verbose = false
console.log('=== Agent Update Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
// Test 1: agent update --help shows options
{
console.log('Test 1: agent update --help shows options')
const result = await $`npx paseo agent update --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent update --help should exit 0')
assert(result.stdout.includes('--name'), 'help should mention --name flag')
assert(result.stdout.includes('--label'), 'help should mention --label flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
console.log('✓ agent update --help shows options\n')
}
// Test 2: agent update requires ID argument
{
console.log('Test 2: agent update requires ID argument')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ agent update requires ID argument\n')
}
// Test 3: agent update requires at least one update field
{
console.log('Test 3: agent update requires update field')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without --name/--label')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('nothing to update') ||
output.toLowerCase().includes('name') ||
output.toLowerCase().includes('label')
assert(hasError, 'error should mention missing update fields')
console.log('✓ agent update requires update field\n')
}
// Test 4: agent update handles daemon not running
{
console.log('Test 4: agent update handles daemon not running')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error should mention connection issue')
console.log('✓ agent update handles daemon not running\n')
}
// Test 5: agent update accepts multiple/comma-separated labels
{
console.log('Test 5: agent update accepts multi-label syntax')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label ui=true,area=frontend --label priority=high`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --label flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent update accepts multi-label syntax\n')
}
// Test 6: agent --help shows update subcommand
{
console.log('Test 6: agent --help shows update subcommand')
const result = await $`npx paseo agent --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
assert(result.stdout.includes('update'), 'help should mention update subcommand')
console.log('✓ agent --help shows update subcommand\n')
}
// Test 7: top-level update alias --help works
{
console.log('Test 7: top-level update --help works')
const result = await $`npx paseo update --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'update --help should exit 0')
assert(result.stdout.includes('--name'), 'help should mention --name flag')
assert(result.stdout.includes('--label'), 'help should mention --label flag')
console.log('✓ top-level update --help works\n')
}
// Test 8: top-level update alias accepts flags
{
console.log('Test 8: top-level update alias accepts flags')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo update abc123 --name "Alias Name" --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept top-level update flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ top-level update alias accepts flags\n')
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== All agent update tests passed ===')
@@ -1074,6 +1074,40 @@ export class DaemonClient {
return { archivedAt: result.archivedAt };
}
async updateAgent(
agentId: string,
updates: { name?: string; labels?: Record<string, string> }
): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "update_agent_request",
agentId,
...(updates.name !== undefined ? { name: updates.name } : {}),
...(updates.labels && Object.keys(updates.labels).length > 0
? { labels: updates.labels }
: {}),
requestId,
});
const payload = await this.sendRequest({
requestId,
message,
timeout: 10000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "update_agent_response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
});
if (!payload.accepted) {
throw new Error(payload.error ?? "updateAgent rejected");
}
}
async resumeAgent(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>
@@ -527,6 +527,16 @@ export class AgentManager {
this.emitState(agent);
}
async setLabels(
agentId: string,
labels: Record<string, string>
): Promise<void> {
const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels };
await this.persistSnapshot(agent);
this.emitState(agent);
}
notifyAgentState(agentId: string): void {
const agent = this.agents.get(agentId);
if (!agent || agent.internal) {
+91
View File
@@ -1125,6 +1125,15 @@ export class Session {
await this.handleArchiveAgentRequest(msg.agentId, msg.requestId);
break;
case "update_agent_request":
await this.handleUpdateAgentRequest(
msg.agentId,
msg.name,
msg.labels,
msg.requestId
);
break;
case "set_voice_mode":
await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId);
break;
@@ -1506,6 +1515,88 @@ export class Session {
});
}
private async handleUpdateAgentRequest(
agentId: string,
name: string | undefined,
labels: Record<string, string> | undefined,
requestId: string
): Promise<void> {
this.sessionLogger.info(
{ agentId, requestId, hasName: typeof name === "string", labelCount: labels ? Object.keys(labels).length : 0 },
"session: update_agent_request"
);
const normalizedName = name?.trim();
const normalizedLabels =
labels && Object.keys(labels).length > 0 ? labels : undefined;
if (!normalizedName && !normalizedLabels) {
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: "Nothing to update (provide name and/or labels)",
},
});
return;
}
try {
const liveAgent = this.agentManager.getAgent(agentId);
if (liveAgent) {
if (normalizedName) {
await this.agentManager.setTitle(agentId, normalizedName);
}
if (normalizedLabels) {
await this.agentManager.setLabels(agentId, normalizedLabels);
}
} else {
const existing = await this.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
await this.agentStorage.upsert({
...existing,
...(normalizedName ? { title: normalizedName } : {}),
...(normalizedLabels
? { labels: { ...existing.labels, ...normalizedLabels } }
: {}),
});
}
this.emit({
type: "update_agent_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, requestId },
"session: update_agent_request error"
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to update agent: ${error.message}`,
},
});
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "Failed to update agent",
},
});
}
}
/**
* Handle voice mode toggle
*/
+22
View File
@@ -422,6 +422,14 @@ export const ArchiveAgentRequestMessageSchema = z.object({
requestId: z.string(),
});
export const UpdateAgentRequestMessageSchema = z.object({
type: z.literal("update_agent_request"),
agentId: z.string(),
name: z.string().optional(),
labels: z.record(z.string()).optional(),
requestId: z.string(),
});
export const SetVoiceModeMessageSchema = z.object({
type: z.literal("set_voice_mode"),
enabled: z.boolean(),
@@ -660,6 +668,16 @@ export const SetAgentThinkingResponseMessageSchema = z.object({
}),
});
export const UpdateAgentResponseMessageSchema = z.object({
type: z.literal("update_agent_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const SetVoiceModeResponseMessageSchema = z.object({
type: z.literal("set_voice_mode_response"),
payload: z.object({
@@ -971,6 +989,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UnsubscribeAgentUpdatesMessageSchema,
DeleteAgentRequestMessageSchema,
ArchiveAgentRequestMessageSchema,
UpdateAgentRequestMessageSchema,
SetVoiceModeMessageSchema,
SendAgentMessageRequestSchema,
WaitForFinishRequestSchema,
@@ -1809,6 +1828,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModeResponseMessageSchema,
SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema,
UpdateAgentResponseMessageSchema,
WaitForFinishResponseMessageSchema,
AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema,
@@ -1878,6 +1898,7 @@ export type SendAgentMessageResponseMessage = z.infer<
export type SetVoiceModeResponseMessage = z.infer<
typeof SetVoiceModeResponseMessageSchema
>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type WaitForFinishResponseMessage = z.infer<
typeof WaitForFinishResponseMessageSchema
>;
@@ -1917,6 +1938,7 @@ export type SpeechModelsDownloadRequestMessage = z.infer<
>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;