From 103883b5240325a430df2f1fc30a1a7d1d0c1b1d Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Wed, 8 Apr 2026 09:54:52 -0700 Subject: [PATCH 1/3] feat: update agents support in rli command line This adds support for agent creation and deletion via the RLI command-line interface. This also improves the 'list' display in a few ways and adds some augmented unit tests. Orinally from Jason's jason/agents_tab branch, with additional updates from Rob. --- README.md | 9 + src/commands/agent/create.ts | 106 ++++++ src/commands/agent/delete.ts | 93 ++++++ src/commands/agent/list.ts | 31 +- src/commands/devbox/create.ts | 31 ++ src/services/agentService.ts | 89 ++++- src/utils/commands.ts | 56 +++- tests/__tests__/commands/agent/create.test.ts | 310 ++++++++++++++++++ tests/__tests__/commands/agent/delete.test.ts | 183 +++++++++++ tests/__tests__/commands/agent/list.test.ts | 71 ++++ 10 files changed, 954 insertions(+), 25 deletions(-) create mode 100644 src/commands/agent/create.ts create mode 100644 src/commands/agent/delete.ts create mode 100644 tests/__tests__/commands/agent/create.test.ts create mode 100644 tests/__tests__/commands/agent/delete.test.ts diff --git a/README.md b/README.md index 17c93f9..7e881e8 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,15 @@ rli benchmark-job logs # Download devbox logs for all scenario rli benchmark-job list # List benchmark jobs ``` +### Agent Commands (alias: `agt`) + +```bash +rli agent list # List agents +rli agent create # Create a new agent +rli agent delete # Delete an agent +rli agent show # Show agent details +``` + ## MCP Server (AI Integration) diff --git a/src/commands/agent/create.ts b/src/commands/agent/create.ts new file mode 100644 index 0000000..3228cdd --- /dev/null +++ b/src/commands/agent/create.ts @@ -0,0 +1,106 @@ +/** + * Create agent command + */ +import chalk from "chalk"; +import { createAgent } from "../../services/agentService.js"; +import { output, outputError } from "../../utils/output.js"; + +interface CreateOptions { + name: string; + agentVersion: string; + source: string; + package?: string; + registryUrl?: string; + repository?: string; + ref?: string; + objectId?: string; + setupCommands?: string[]; + output?: string; +} + +export async function createAgentCommand( + options: CreateOptions, +): Promise { + try { + const sourceType = options.source; + let source: any; + + switch (sourceType) { + case "npm": + if (!options.package) { + throw new Error("--package is required for npm source type"); + } + source = { + type: "npm", + npm: { + package_name: options.package, + registry_url: options.registryUrl || undefined, + agent_setup: options.setupCommands || undefined, + }, + }; + break; + case "pip": + if (!options.package) { + throw new Error("--package is required for pip source type"); + } + source = { + type: "pip", + pip: { + package_name: options.package, + registry_url: options.registryUrl || undefined, + agent_setup: options.setupCommands || undefined, + }, + }; + break; + case "git": + if (!options.repository) { + throw new Error("--repository is required for git source type"); + } + source = { + type: "git", + git: { + repository: options.repository, + ref: options.ref || undefined, + agent_setup: options.setupCommands || undefined, + }, + }; + break; + case "object": + if (!options.objectId) { + throw new Error("--object-id is required for object source type"); + } + source = { + type: "object", + object: { + object_id: options.objectId, + agent_setup: options.setupCommands || undefined, + }, + }; + break; + default: + throw new Error( + `Unknown source type: ${sourceType}. Use npm, pip, git, or object.`, + ); + } + + const agent = await createAgent({ + name: options.name, + version: options.agentVersion, + source, + }); + + const format = options.output || "text"; + if (format !== "text") { + output(agent, { format, defaultFormat: "json" }); + } else { + console.log(chalk.green("✓") + " Agent created successfully"); + console.log(); + console.log(` ${chalk.bold("Name:")} ${agent.name}`); + console.log(` ${chalk.bold("ID:")} ${chalk.dim(agent.id)}`); + console.log(` ${chalk.bold("Version:")} ${agent.version}`); + console.log(` ${chalk.bold("Source:")} ${sourceType}`); + } + } catch (error) { + outputError("Failed to create agent", error); + } +} diff --git a/src/commands/agent/delete.ts b/src/commands/agent/delete.ts new file mode 100644 index 0000000..302c9dd --- /dev/null +++ b/src/commands/agent/delete.ts @@ -0,0 +1,93 @@ +/** + * Delete agent command + */ +import chalk from "chalk"; +import readline from "readline"; +import { + getAgent, + listAgents, + deleteAgent, +} from "../../services/agentService.js"; +import { printAgentTable } from "./list.js"; +import { output, outputError } from "../../utils/output.js"; + +interface DeleteOptions { + yes?: boolean; + output?: string; +} + +async function confirm(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(message, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }); + }); +} + +export async function deleteAgentCommand( + idOrName: string, + options: DeleteOptions, +): Promise { + try { + // Direct ID lookup + if (idOrName.startsWith("agt_")) { + const agent = await getAgent(idOrName); + await confirmAndDelete(agent, options); + return; + } + + // Name lookup — require exactly one match + const result = await listAgents({ name: idOrName }); + const matches = result.agents.filter((a) => a.name === idOrName); + + if (matches.length === 0) { + throw new Error(`No agent found with name: ${idOrName}`); + } + + if (matches.length > 1) { + console.log( + `Multiple agents found with name "${idOrName}". Delete by ID instead:`, + ); + console.log(); + printAgentTable(matches); + return; + } + + await confirmAndDelete(matches[0], options); + } catch (error) { + outputError("Failed to delete agent", error); + } +} + +async function confirmAndDelete( + agent: { id: string; name: string }, + options: DeleteOptions, +): Promise { + if (!options.yes) { + const confirmed = await confirm( + `Delete agent "${agent.name}" (${agent.id})? [y/N] `, + ); + if (!confirmed) { + console.log(chalk.dim("Cancelled")); + return; + } + } + + await deleteAgent(agent.id); + + const format = options.output || "text"; + if (format !== "text") { + output( + { deleted: true, id: agent.id, name: agent.name }, + { format, defaultFormat: "json" }, + ); + } else { + console.log(chalk.green("✓") + ` Agent "${agent.name}" (${agent.id}) deleted`); + } +} diff --git a/src/commands/agent/list.ts b/src/commands/agent/list.ts index e0617bd..0ec84fd 100644 --- a/src/commands/agent/list.ts +++ b/src/commands/agent/list.ts @@ -96,18 +96,10 @@ function padStyled(raw: string, styled: string, width: number): string { return styled + " ".repeat(Math.max(0, width - raw.length)); } -function printTable(agents: Agent[], isPublic: boolean): void { - if (isPublic) { - console.log( - chalk.dim("Showing PUBLIC agents. Use --private to see private agents"), - ); - } else { - console.log( - chalk.dim("Showing PRIVATE agents. Use --public to see public agents"), - ); - } - console.log(); - +/** + * Render a table of agents to stdout. Reusable by other commands. + */ +export function printAgentTable(agents: Agent[]): void { if (agents.length === 0) { console.log(chalk.dim("No agents found")); return; @@ -135,6 +127,21 @@ function printTable(agents: Agent[], isPublic: boolean): void { ); } +function printTable(agents: Agent[], isPublic: boolean): void { + if (isPublic) { + console.log( + chalk.dim("Showing PUBLIC agents. Use --private to see private agents"), + ); + } else { + console.log( + chalk.dim("Showing PRIVATE agents. Use --public to see public agents"), + ); + } + console.log(); + + printAgentTable(agents); +} + /** * Keep only the most recently created agent for each name. */ diff --git a/src/commands/devbox/create.ts b/src/commands/devbox/create.ts index 7377d57..1340d58 100644 --- a/src/commands/devbox/create.ts +++ b/src/commands/devbox/create.ts @@ -4,6 +4,11 @@ import { getClient } from "../../utils/client.js"; import { output, outputError } from "../../utils/output.js"; +import { + listAgents, + getAgent, + type Agent, +} from "../../services/agentService.js"; interface CreateOptions { name?: string; @@ -26,6 +31,7 @@ interface CreateOptions { tunnel?: string; gateways?: string[]; mcp?: string[]; + agent?: string; output?: string; } @@ -147,6 +153,19 @@ function parseMcpSpecs( return result; } +async function resolveAgent(idOrName: string): Promise { + if (idOrName.startsWith("agt_")) { + return getAgent(idOrName); + } + const result = await listAgents({ name: idOrName }); + const matches = result.agents.filter((a) => a.name === idOrName); + if (matches.length === 0) { + throw new Error(`No agent found with name: ${idOrName}`); + } + matches.sort((a, b) => b.create_time_ms - a.create_time_ms); + return matches[0]; +} + export async function createDevbox(options: CreateOptions = {}) { try { const client = getClient(); @@ -273,6 +292,18 @@ export async function createDevbox(options: CreateOptions = {}) { createRequest.mcp = parseMcpSpecs(options.mcp); } + // Handle agent mount + if (options.agent) { + const agent = await resolveAgent(options.agent); + if (!createRequest.mounts) createRequest.mounts = []; + (createRequest.mounts as unknown[]).push({ + type: "agent_mount", + agent_id: agent.id, + agent_name: null, + agent_path: "/home/user", + }); + } + if (Object.keys(launchParameters).length > 0) { createRequest.launch_parameters = launchParameters; } diff --git a/src/services/agentService.ts b/src/services/agentService.ts index e053ac4..65318a1 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -65,21 +65,16 @@ export async function listAgents( queryParams.version = options.version; } - const page = await client.agents.list(queryParams); - const agents: Agent[] = []; - - // Collect agents from the cursor page - for await (const agent of page) { - agents.push(agent); - if (options.limit && agents.length >= options.limit) { - break; - } - } + // Use raw HTTP to get has_more from the API response directly + const response = await (client as any).get("/v1/agents", { + query: queryParams, + }); + const agents: Agent[] = response.agents || []; return { agents, totalCount: agents.length, - hasMore: false, // Cursor pagination doesn't give us this directly + hasMore: response.has_more || false, }; } @@ -90,3 +85,75 @@ export async function getAgent(id: string): Promise { const client = getClient(); return client.agents.retrieve(id); } + +/** + * List public agents with pagination + */ +export async function listPublicAgents( + options: ListAgentsOptions, +): Promise { + const client = getClient(); + + const queryParams: Record = { + limit: options.limit || 50, + }; + + if (options.startingAfter) { + queryParams.starting_after = options.startingAfter; + } + if (options.name) { + queryParams.name = options.name; + } + if (options.search) { + queryParams.search = options.search; + } + + // SDK doesn't have agents.listPublic yet, use raw HTTP call + const response = await (client as any).get("/v1/agents/list_public", { + query: queryParams, + }); + const agents: Agent[] = response.agents || []; + + return { + agents, + totalCount: agents.length, + hasMore: response.has_more || false, + }; +} + +export interface CreateAgentOptions { + name: string; + version: string; + source?: { + type: string; + npm?: { + package_name: string; + registry_url?: string; + agent_setup?: string[]; + }; + pip?: { + package_name: string; + registry_url?: string; + agent_setup?: string[]; + }; + git?: { repository: string; ref?: string; agent_setup?: string[] }; + object?: { object_id: string; agent_setup?: string[] }; + }; +} + +/** + * Create a new agent + */ +export async function createAgent(options: CreateAgentOptions): Promise { + const client = getClient(); + return client.agents.create(options); +} + +/** + * Delete an agent by ID + */ +export async function deleteAgent(id: string): Promise { + const client = getClient(); + // SDK doesn't have agents.delete yet, use raw HTTP call + await (client as any).post(`/v1/agents/${id}/delete`); +} diff --git a/src/utils/commands.ts b/src/utils/commands.ts index e42406e..aa4fa39 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -16,10 +16,21 @@ export function createProgram(): Command { program .name("rli") .description("Beautiful CLI for Runloop devbox management") - .version(VERSION) .showHelpAfterError() .showSuggestionAfterError(); + // Custom --version handling: warn when other args are present + program.option("-V, --version", "output the version number"); + program.on("option:version", () => { + const otherArgs = process.argv.slice(2).filter((a) => a !== "--version" && a !== "-V"); + if (otherArgs.length > 0) { + console.log(`RLI version: ${VERSION} (other args ignored)`); + } else { + console.log(VERSION); + } + process.exit(0); + }); + // Devbox commands const devbox = program .command("devbox") @@ -73,6 +84,7 @@ export function createProgram(): Command { "--mcp ", "MCP configurations (format: ENV_VAR_NAME=mcp_config_id_or_name,secret_id_or_name)", ) + .option("--agent ", "Agent to mount (name or ID)") .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", @@ -1149,7 +1161,7 @@ export function createProgram(): Command { // Agent commands const agent = program - .command("agent", { hidden: true }) + .command("agent") .description("Manage agents") .alias("agt"); @@ -1170,6 +1182,46 @@ export function createProgram(): Command { await listAgentsCommand(options); }); + agent + .command("create") + .description("Create a new agent") + .requiredOption("--name ", "Agent name") + .requiredOption("--agent-version ", "Version string (semver or SHA)") + .requiredOption("--source ", "Source type: npm|pip|git|object") + .option("--package ", "Package name (for npm/pip sources)") + .option("--registry-url ", "Registry URL (for npm/pip sources)") + .option("--repository ", "Git repository URL (for git source)") + .option("--ref ", "Git ref - branch/tag/commit (for git source)") + .option("--object-id ", "Object ID (for object source)") + .option( + "--setup-commands ", + "Setup commands to run after installation", + ) + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (options) => { + const { createAgentCommand } = + await import("../commands/agent/create.js"); + await createAgentCommand(options); + }); + + agent + .command("delete ") + .description("Delete an agent") + .alias("rm") + .option("-y, --yes", "Skip confirmation prompt") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (idOrName, options) => { + const { deleteAgentCommand } = + await import("../commands/agent/delete.js"); + await deleteAgentCommand(idOrName, options); + }); + agent .command("show ") .description("Show agent details") diff --git a/tests/__tests__/commands/agent/create.test.ts b/tests/__tests__/commands/agent/create.test.ts new file mode 100644 index 0000000..c181cdf --- /dev/null +++ b/tests/__tests__/commands/agent/create.test.ts @@ -0,0 +1,310 @@ +/** + * Tests for agent create command + */ + +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockCreateAgent = jest.fn(); +jest.unstable_mockModule("@/services/agentService.js", () => ({ + createAgent: mockCreateAgent, +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, +})); + +const sampleAgent = { + id: "agt_abc123", + name: "my-agent", + version: "1.0.0", + is_public: false, + create_time_ms: Date.now(), +}; + +describe("createAgentCommand", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateAgent.mockReset(); + mockOutput.mockReset(); + mockOutputError.mockReset(); + }); + + it("should create an npm agent with required options", async () => { + mockCreateAgent.mockResolvedValue(sampleAgent); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "npm", + package: "my-npm-package", + }); + + logSpy.mockRestore(); + + expect(mockCreateAgent).toHaveBeenCalledWith({ + name: "my-agent", + version: "1.0.0", + source: { + type: "npm", + npm: { + package_name: "my-npm-package", + registry_url: undefined, + agent_setup: undefined, + }, + }, + }); + }); + + it("should error when npm source missing --package", async () => { + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "npm", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create agent", + expect.objectContaining({ + message: expect.stringContaining("--package is required"), + }), + ); + expect(mockCreateAgent).not.toHaveBeenCalled(); + }); + + it("should create a pip agent", async () => { + mockCreateAgent.mockResolvedValue(sampleAgent); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "pip", + package: "my-pip-package", + registryUrl: "https://custom.pypi.org/simple", + }); + + logSpy.mockRestore(); + + expect(mockCreateAgent).toHaveBeenCalledWith( + expect.objectContaining({ + source: { + type: "pip", + pip: { + package_name: "my-pip-package", + registry_url: "https://custom.pypi.org/simple", + agent_setup: undefined, + }, + }, + }), + ); + }); + + it("should error when pip source missing --package", async () => { + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "pip", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create agent", + expect.objectContaining({ + message: expect.stringContaining("--package is required"), + }), + ); + }); + + it("should create a git agent", async () => { + mockCreateAgent.mockResolvedValue(sampleAgent); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "git", + repository: "https://github.com/org/repo", + ref: "main", + }); + + logSpy.mockRestore(); + + expect(mockCreateAgent).toHaveBeenCalledWith( + expect.objectContaining({ + source: { + type: "git", + git: { + repository: "https://github.com/org/repo", + ref: "main", + agent_setup: undefined, + }, + }, + }), + ); + }); + + it("should error when git source missing --repository", async () => { + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "git", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create agent", + expect.objectContaining({ + message: expect.stringContaining("--repository is required"), + }), + ); + }); + + it("should create an object agent", async () => { + mockCreateAgent.mockResolvedValue(sampleAgent); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "object", + objectId: "obj_12345", + }); + + logSpy.mockRestore(); + + expect(mockCreateAgent).toHaveBeenCalledWith( + expect.objectContaining({ + source: { + type: "object", + object: { + object_id: "obj_12345", + agent_setup: undefined, + }, + }, + }), + ); + }); + + it("should error when object source missing --object-id", async () => { + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "object", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create agent", + expect.objectContaining({ + message: expect.stringContaining("--object-id is required"), + }), + ); + }); + + it("should error on unknown source type", async () => { + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "docker", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create agent", + expect.objectContaining({ + message: expect.stringContaining("Unknown source type: docker"), + }), + ); + }); + + it("should output JSON format when requested", async () => { + mockCreateAgent.mockResolvedValue(sampleAgent); + + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "npm", + package: "my-pkg", + output: "json", + }); + + expect(mockOutput).toHaveBeenCalledWith(sampleAgent, { + format: "json", + defaultFormat: "json", + }); + }); + + it("should print text summary on success", async () => { + mockCreateAgent.mockResolvedValue(sampleAgent); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "npm", + package: "my-pkg", + }); + + const allOutput = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(allOutput).toContain("Agent created successfully"); + expect(allOutput).toContain("my-agent"); + expect(allOutput).toContain("agt_abc123"); + + logSpy.mockRestore(); + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API Error"); + mockCreateAgent.mockRejectedValue(apiError); + + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "npm", + package: "my-pkg", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create agent", + apiError, + ); + }); + + it("should pass setup commands through", async () => { + mockCreateAgent.mockResolvedValue(sampleAgent); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "npm", + package: "my-pkg", + setupCommands: ["npm install", "npm run build"], + }); + + logSpy.mockRestore(); + + expect(mockCreateAgent).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ + npm: expect.objectContaining({ + agent_setup: ["npm install", "npm run build"], + }), + }), + }), + ); + }); +}); diff --git a/tests/__tests__/commands/agent/delete.test.ts b/tests/__tests__/commands/agent/delete.test.ts new file mode 100644 index 0000000..561d4c3 --- /dev/null +++ b/tests/__tests__/commands/agent/delete.test.ts @@ -0,0 +1,183 @@ +/** + * Tests for agent delete command + */ + +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockGetAgent = jest.fn(); +const mockListAgents = jest.fn(); +const mockDeleteAgent = jest.fn(); +jest.unstable_mockModule("@/services/agentService.js", () => ({ + getAgent: mockGetAgent, + listAgents: mockListAgents, + deleteAgent: mockDeleteAgent, +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, +})); + +const mockPrintAgentTable = jest.fn(); +jest.unstable_mockModule("@/commands/agent/list.js", () => ({ + printAgentTable: mockPrintAgentTable, +})); + +// Mock readline to simulate user confirmation +const mockQuestion = jest.fn(); +const mockClose = jest.fn(); +jest.unstable_mockModule("readline", () => ({ + default: { + createInterface: () => ({ + question: mockQuestion, + close: mockClose, + }), + }, +})); + +const sampleAgent = { + id: "agt_abc123", + name: "my-agent", + version: "1.0.0", + is_public: false, + create_time_ms: Date.now(), +}; + +describe("deleteAgentCommand", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAgent.mockReset(); + mockListAgents.mockReset(); + mockDeleteAgent.mockReset(); + mockOutput.mockReset(); + mockOutputError.mockReset(); + mockPrintAgentTable.mockReset(); + mockQuestion.mockReset(); + mockClose.mockReset(); + }); + + it("should delete by ID with --yes flag", async () => { + mockGetAgent.mockResolvedValue(sampleAgent); + mockDeleteAgent.mockResolvedValue(undefined); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { deleteAgentCommand } = await import("@/commands/agent/delete.js"); + await deleteAgentCommand("agt_abc123", { yes: true }); + + logSpy.mockRestore(); + + expect(mockGetAgent).toHaveBeenCalledWith("agt_abc123"); + expect(mockDeleteAgent).toHaveBeenCalledWith("agt_abc123"); + expect(mockQuestion).not.toHaveBeenCalled(); + }); + + it("should resolve name to ID before deleting", async () => { + mockListAgents.mockResolvedValue({ + agents: [sampleAgent], + }); + mockDeleteAgent.mockResolvedValue(undefined); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { deleteAgentCommand } = await import("@/commands/agent/delete.js"); + await deleteAgentCommand("my-agent", { yes: true }); + + logSpy.mockRestore(); + + expect(mockListAgents).toHaveBeenCalledWith({ name: "my-agent" }); + expect(mockDeleteAgent).toHaveBeenCalledWith("agt_abc123"); + }); + + it("should list matches and not delete when name matches multiple", async () => { + const older = { ...sampleAgent, id: "agt_old", create_time_ms: 1000 }; + const newer = { ...sampleAgent, id: "agt_new", create_time_ms: 2000 }; + mockListAgents.mockResolvedValue({ agents: [older, newer] }); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { deleteAgentCommand } = await import("@/commands/agent/delete.js"); + await deleteAgentCommand("my-agent", { yes: true }); + + const allOutput = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(allOutput).toContain("Multiple agents found"); + expect(allOutput).toContain("Delete by ID"); + expect(mockPrintAgentTable).toHaveBeenCalledWith([older, newer]); + expect(mockDeleteAgent).not.toHaveBeenCalled(); + + logSpy.mockRestore(); + }); + + it("should prompt for confirmation without --yes", async () => { + mockGetAgent.mockResolvedValue(sampleAgent); + mockDeleteAgent.mockResolvedValue(undefined); + mockQuestion.mockImplementation((_msg: unknown, cb: unknown) => { + (cb as (answer: string) => void)("y"); + }); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { deleteAgentCommand } = await import("@/commands/agent/delete.js"); + await deleteAgentCommand("agt_abc123", {}); + + logSpy.mockRestore(); + + expect(mockQuestion).toHaveBeenCalled(); + expect(mockDeleteAgent).toHaveBeenCalledWith("agt_abc123"); + }); + + it("should cancel when user declines confirmation", async () => { + mockGetAgent.mockResolvedValue(sampleAgent); + mockQuestion.mockImplementation((_msg: unknown, cb: unknown) => { + (cb as (answer: string) => void)("n"); + }); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const { deleteAgentCommand } = await import("@/commands/agent/delete.js"); + await deleteAgentCommand("agt_abc123", {}); + + logSpy.mockRestore(); + + expect(mockDeleteAgent).not.toHaveBeenCalled(); + }); + + it("should output JSON format when requested", async () => { + mockGetAgent.mockResolvedValue(sampleAgent); + mockDeleteAgent.mockResolvedValue(undefined); + + const { deleteAgentCommand } = await import("@/commands/agent/delete.js"); + await deleteAgentCommand("agt_abc123", { yes: true, output: "json" }); + + expect(mockOutput).toHaveBeenCalledWith( + { deleted: true, id: "agt_abc123", name: "my-agent" }, + { format: "json", defaultFormat: "json" }, + ); + }); + + it("should error when name not found", async () => { + mockListAgents.mockResolvedValue({ agents: [] }); + + const { deleteAgentCommand } = await import("@/commands/agent/delete.js"); + await deleteAgentCommand("nonexistent", { yes: true }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to delete agent", + expect.objectContaining({ + message: expect.stringContaining("No agent found"), + }), + ); + expect(mockDeleteAgent).not.toHaveBeenCalled(); + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API Error"); + mockGetAgent.mockResolvedValue(sampleAgent); + mockDeleteAgent.mockRejectedValue(apiError); + + const { deleteAgentCommand } = await import("@/commands/agent/delete.js"); + await deleteAgentCommand("agt_abc123", { yes: true }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to delete agent", + apiError, + ); + }); +}); diff --git a/tests/__tests__/commands/agent/list.test.ts b/tests/__tests__/commands/agent/list.test.ts index a09f1b0..8e844ec 100644 --- a/tests/__tests__/commands/agent/list.test.ts +++ b/tests/__tests__/commands/agent/list.test.ts @@ -125,6 +125,77 @@ describe("listAgentsCommand", () => { ); }); + it("should show PRIVATE banner by default", async () => { + mockListAgents.mockResolvedValue({ agents: sampleAgents }); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + await listAgentsCommand({}); + + const allOutput = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(allOutput).toContain("PRIVATE"); + expect(allOutput).toContain("--public"); + + logSpy.mockRestore(); + }); + + it("should show PUBLIC banner with --public flag", async () => { + mockListAgents.mockResolvedValue({ agents: [] }); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + await listAgentsCommand({ public: true }); + + const allOutput = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(allOutput).toContain("PUBLIC"); + expect(allOutput).toContain("--private"); + + logSpy.mockRestore(); + }); + + it("should size columns to fit content", async () => { + const agents = [ + { + id: "agt_short", + name: "a", + version: "1", + is_public: false, + create_time_ms: 1000, + }, + { + id: "agt_a_much_longer_id_value", + name: "a-much-longer-agent-name", + version: "12.345.6789", + is_public: false, + create_time_ms: 2000, + }, + ]; + mockListAgents.mockResolvedValue({ agents }); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + await listAgentsCommand({}); + + // Find the header line (first line after the banner and blank line) + const lines = logSpy.mock.calls.map((c) => String(c[0])); + // Header row contains all column names + const headerLine = lines.find( + (l) => l.includes("NAME") && l.includes("ID") && l.includes("VERSION"), + ); + expect(headerLine).toBeDefined(); + + // The two data rows should have their IDs starting at the same column offset + const dataLines = lines.filter((l) => l.includes("agt_")); + expect(dataLines).toHaveLength(2); + + // Both IDs should be at the same column position (aligned) + const idPos0 = dataLines[0].indexOf("agt_"); + const idPos1 = dataLines[1].indexOf("agt_"); + expect(idPos0).toBe(idPos1); + + logSpy.mockRestore(); + }); + it("should handle API errors gracefully", async () => { const apiError = new Error("API Error"); mockListAgents.mockRejectedValue(apiError); From f22430e3e371bf9860323da3acab6401435bd0be Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Wed, 8 Apr 2026 10:03:12 -0700 Subject: [PATCH 2/3] fmt --- src/commands/agent/delete.ts | 4 +++- src/utils/commands.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commands/agent/delete.ts b/src/commands/agent/delete.ts index 302c9dd..71fb044 100644 --- a/src/commands/agent/delete.ts +++ b/src/commands/agent/delete.ts @@ -88,6 +88,8 @@ async function confirmAndDelete( { format, defaultFormat: "json" }, ); } else { - console.log(chalk.green("✓") + ` Agent "${agent.name}" (${agent.id}) deleted`); + console.log( + chalk.green("✓") + ` Agent "${agent.name}" (${agent.id}) deleted`, + ); } } diff --git a/src/utils/commands.ts b/src/utils/commands.ts index aa4fa39..af6ee7c 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -22,7 +22,9 @@ export function createProgram(): Command { // Custom --version handling: warn when other args are present program.option("-V, --version", "output the version number"); program.on("option:version", () => { - const otherArgs = process.argv.slice(2).filter((a) => a !== "--version" && a !== "-V"); + const otherArgs = process.argv + .slice(2) + .filter((a) => a !== "--version" && a !== "-V"); if (otherArgs.length > 0) { console.log(`RLI version: ${VERSION} (other args ignored)`); } else { @@ -1186,7 +1188,10 @@ export function createProgram(): Command { .command("create") .description("Create a new agent") .requiredOption("--name ", "Agent name") - .requiredOption("--agent-version ", "Version string (semver or SHA)") + .requiredOption( + "--agent-version ", + "Version string (semver or SHA)", + ) .requiredOption("--source ", "Source type: npm|pip|git|object") .option("--package ", "Package name (for npm/pip sources)") .option("--registry-url ", "Registry URL (for npm/pip sources)") From e3ce131aa4b61691f8fe504349b180d92590f18e Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Wed, 8 Apr 2026 13:10:18 -0700 Subject: [PATCH 3/3] address comments - more specific error checking for different agent creation cases along with better typescript type handling - cleaned up agent path specification in devbox creation, including fixed some errors there - improved some of the unit tests --- src/commands/agent/create.ts | 138 ++++++++++-------- src/commands/devbox/create.ts | 27 +++- src/services/agentService.ts | 4 +- src/utils/commands.ts | 3 +- tests/__tests__/commands/agent/create.test.ts | 57 ++++++++ 5 files changed, 159 insertions(+), 70 deletions(-) diff --git a/src/commands/agent/create.ts b/src/commands/agent/create.ts index 3228cdd..bf18717 100644 --- a/src/commands/agent/create.ts +++ b/src/commands/agent/create.ts @@ -18,75 +18,93 @@ interface CreateOptions { output?: string; } +// Maps each source type to the options it accepts (beyond --setup-commands, which all types accept) +const validOptionsBySource: Record = { + npm: ["package", "registryUrl"], + pip: ["package", "registryUrl"], + git: ["repository", "ref"], + object: ["objectId"], +}; + +// All source-specific option flags (for error messages) +const allSourceOptions: { key: keyof CreateOptions; flag: string }[] = [ + { key: "package", flag: "--package" }, + { key: "registryUrl", flag: "--registry-url" }, + { key: "repository", flag: "--repository" }, + { key: "ref", flag: "--ref" }, + { key: "objectId", flag: "--object-id" }, +]; + +function rejectInvalidOptions( + sourceType: string, + options: CreateOptions, +): void { + const allowed = validOptionsBySource[sourceType] || []; + const invalid = allSourceOptions + .filter( + (opt) => options[opt.key] !== undefined && !allowed.includes(opt.key), + ) + .map((opt) => opt.flag); + + if (invalid.length > 0) { + throw new Error( + `${invalid.join(", ")} cannot be used with ${sourceType} source type`, + ); + } +} + +function buildSourceOptions( + sourceType: string, + options: CreateOptions, +): Record { + rejectInvalidOptions(sourceType, options); + + switch (sourceType) { + case "npm": + case "pip": + if (!options.package) { + throw new Error(`--package is required for ${sourceType} source type`); + } + return { + package_name: options.package, + registry_url: options.registryUrl || undefined, + agent_setup: options.setupCommands || undefined, + }; + case "git": + if (!options.repository) { + throw new Error("--repository is required for git source type"); + } + return { + repository: options.repository, + ref: options.ref || undefined, + agent_setup: options.setupCommands || undefined, + }; + case "object": + if (!options.objectId) { + throw new Error("--object-id is required for object source type"); + } + return { + object_id: options.objectId, + agent_setup: options.setupCommands || undefined, + }; + default: + throw new Error( + `Unknown source type: ${sourceType}. Use npm, pip, git, or object.`, + ); + } +} + export async function createAgentCommand( options: CreateOptions, ): Promise { try { const sourceType = options.source; - let source: any; - - switch (sourceType) { - case "npm": - if (!options.package) { - throw new Error("--package is required for npm source type"); - } - source = { - type: "npm", - npm: { - package_name: options.package, - registry_url: options.registryUrl || undefined, - agent_setup: options.setupCommands || undefined, - }, - }; - break; - case "pip": - if (!options.package) { - throw new Error("--package is required for pip source type"); - } - source = { - type: "pip", - pip: { - package_name: options.package, - registry_url: options.registryUrl || undefined, - agent_setup: options.setupCommands || undefined, - }, - }; - break; - case "git": - if (!options.repository) { - throw new Error("--repository is required for git source type"); - } - source = { - type: "git", - git: { - repository: options.repository, - ref: options.ref || undefined, - agent_setup: options.setupCommands || undefined, - }, - }; - break; - case "object": - if (!options.objectId) { - throw new Error("--object-id is required for object source type"); - } - source = { - type: "object", - object: { - object_id: options.objectId, - agent_setup: options.setupCommands || undefined, - }, - }; - break; - default: - throw new Error( - `Unknown source type: ${sourceType}. Use npm, pip, git, or object.`, - ); - } + const sourceOptions = buildSourceOptions(sourceType, options); const agent = await createAgent({ name: options.name, version: options.agentVersion, - source, + source: { type: sourceType, [sourceType]: sourceOptions }, }); const format = options.output || "text"; diff --git a/src/commands/devbox/create.ts b/src/commands/devbox/create.ts index 1340d58..bbcb98e 100644 --- a/src/commands/devbox/create.ts +++ b/src/commands/devbox/create.ts @@ -31,7 +31,8 @@ interface CreateOptions { tunnel?: string; gateways?: string[]; mcp?: string[]; - agent?: string; + agent?: string[]; + agentPath?: string; output?: string; } @@ -293,15 +294,27 @@ export async function createDevbox(options: CreateOptions = {}) { } // Handle agent mount - if (options.agent) { - const agent = await resolveAgent(options.agent); - if (!createRequest.mounts) createRequest.mounts = []; - (createRequest.mounts as unknown[]).push({ + if (options.agent && options.agent.length > 0) { + if (options.agent.length > 1) { + throw new Error( + "Mounting multiple agents via rli is not supported yet", + ); + } + const agent = await resolveAgent(options.agent[0]); + const mount: Record = { type: "agent_mount", agent_id: agent.id, agent_name: null, - agent_path: "/home/user", - }); + }; + // agent_path only makes sense for git and object agents. Since + // we don't know at this stage what type of agent it is, + // however, we'll let the server error inform the user if they + // add this option in a case where it doesn't make sense. + if (options.agentPath) { + mount.agent_path = options.agentPath; + } + if (!createRequest.mounts) createRequest.mounts = []; + (createRequest.mounts as unknown[]).push(mount); } if (Object.keys(launchParameters).length > 0) { diff --git a/src/services/agentService.ts b/src/services/agentService.ts index 65318a1..b2ec9fe 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -40,7 +40,7 @@ export async function listAgents( search?: string; version?: string; } = { - limit: options.limit || 50, + limit: options.limit, }; if (options.startingAfter) { @@ -95,7 +95,7 @@ export async function listPublicAgents( const client = getClient(); const queryParams: Record = { - limit: options.limit || 50, + limit: options.limit, }; if (options.startingAfter) { diff --git a/src/utils/commands.ts b/src/utils/commands.ts index af6ee7c..f1c4ce8 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -86,7 +86,8 @@ export function createProgram(): Command { "--mcp ", "MCP configurations (format: ENV_VAR_NAME=mcp_config_id_or_name,secret_id_or_name)", ) - .option("--agent ", "Agent to mount (name or ID)") + .option("--agent ", "Agent to mount (name or ID)") + .option("--agent-path ", "Path to mount the agent on the devbox") .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", diff --git a/tests/__tests__/commands/agent/create.test.ts b/tests/__tests__/commands/agent/create.test.ts index c181cdf..ab7bb39 100644 --- a/tests/__tests__/commands/agent/create.test.ts +++ b/tests/__tests__/commands/agent/create.test.ts @@ -210,6 +210,63 @@ describe("createAgentCommand", () => { ); }); + it("should error when git source gets --package", async () => { + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "git", + repository: "https://github.com/org/repo", + package: "bad-pkg", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create agent", + expect.objectContaining({ + message: expect.stringContaining("--package"), + }), + ); + expect(mockCreateAgent).not.toHaveBeenCalled(); + }); + + it("should error when npm source gets --repository", async () => { + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "npm", + package: "my-pkg", + repository: "https://github.com/org/repo", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create agent", + expect.objectContaining({ + message: expect.stringContaining("--repository"), + }), + ); + expect(mockCreateAgent).not.toHaveBeenCalled(); + }); + + it("should error when object source gets --ref", async () => { + const { createAgentCommand } = await import("@/commands/agent/create.js"); + await createAgentCommand({ + name: "my-agent", + agentVersion: "1.0.0", + source: "object", + objectId: "obj_123", + ref: "main", + }); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to create agent", + expect.objectContaining({ + message: expect.stringContaining("--ref"), + }), + ); + expect(mockCreateAgent).not.toHaveBeenCalled(); + }); + it("should error on unknown source type", async () => { const { createAgentCommand } = await import("@/commands/agent/create.js"); await createAgentCommand({