From f4119b6461e51b0739676c15cc8d1aeceecf6743 Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Mon, 30 Mar 2026 13:22:45 -0700 Subject: [PATCH 1/3] feat(cli): Add some (hidden) agent handling commands Also fix a few other small things: * preserve agent version info where needed * better error messages for bad server calls --- src/services/agentService.ts | 6 +++++ src/utils/commands.ts | 35 +++++++++++++++++++++++++++ src/utils/output.ts | 47 +++++++++++++++++++++++++++--------- 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/services/agentService.ts b/src/services/agentService.ts index 70834659..e053ac48 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -14,6 +14,7 @@ export interface ListAgentsOptions { privateOnly?: boolean; name?: string; search?: string; + version?: string; } export interface ListAgentsResult { @@ -37,6 +38,7 @@ export async function listAgents( is_public?: boolean; name?: string; search?: string; + version?: string; } = { limit: options.limit || 50, }; @@ -59,6 +61,10 @@ export async function listAgents( queryParams.search = options.search; } + if (options.version) { + queryParams.version = options.version; + } + const page = await client.agents.list(queryParams); const agents: Agent[] = []; diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 0a25c86a..6de8e71a 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -1127,6 +1127,41 @@ export function createProgram(): Command { await listBenchmarkJobsCommand(options); }); + // Agent commands + const agent = program + .command("agent", { hidden: true }) + .description("Manage agents") + .alias("agt"); + + agent + .command("list") + .description("List agents") + .option("--full", "Show all versions for all agents") + .option("--name ", "Filter by name (partial match)") + .option("--search ", "Search by agent ID or name") + .option("--public", "Show only public agents") + .option("--private", "Show only private agents") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (options) => { + const { listAgentsCommand } = await import("../commands/agent/list.js"); + await listAgentsCommand(options); + }); + + agent + .command("show ") + .description("Show agent details") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (idOrName, options) => { + const { showAgentCommand } = await import("../commands/agent/show.js"); + await showAgentCommand(idOrName, options); + }); + // Hidden command: 'rli mcp' without subcommand starts the server (for Claude Desktop config compatibility) program .command("mcp-server", { hidden: true }) diff --git a/src/utils/output.ts b/src/utils/output.ts index 95026014..e0f9031f 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -167,20 +167,43 @@ export function output(data: unknown, options: SimpleOutputOptions = {}): void { * outputError('Failed to get devbox', error); */ export function outputError(message: string, error?: Error | unknown): never { - const errorMessage = - error instanceof Error ? error.message : String(error || message); console.error(`Error: ${message}`); - // Only print the error message if it adds new information - // Skip if: same as message, message contains it, or it contains the message - const messageLower = message.toLowerCase(); - const errorLower = errorMessage.toLowerCase(); - const isRedundant = - errorMessage === message || - messageLower.includes(errorLower) || - errorLower.includes(messageLower); - if (error && !isRedundant) { - console.error(` ${errorMessage}`); + + if (error && typeof error === "object") { + // Extract API error details (status code, response body) + const apiError = error as { + status?: number; + error?: unknown; + message?: string; + }; + + if (apiError.status) { + console.error(` HTTP ${apiError.status}`); + } + + // Show the error body if it has useful detail beyond the message + if ( + apiError.error && + typeof apiError.error === "object" && + Object.keys(apiError.error).length > 0 + ) { + const body = JSON.stringify(apiError.error); + console.error(` ${body}`); + } + + // Show the error message if it adds info beyond what we already printed + const errorMessage = error instanceof Error ? error.message : String(error); + const messageLower = message.toLowerCase(); + const errorLower = errorMessage.toLowerCase(); + const isRedundant = + errorMessage === message || + messageLower.includes(errorLower) || + errorLower.includes(messageLower); + if (!isRedundant && !apiError.status) { + console.error(` ${errorMessage}`); + } } + processUtils.exit(1); } From b09e586cb9d644020eed9b738d37be0c88ed45ba Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Mon, 30 Mar 2026 13:26:26 -0700 Subject: [PATCH 2/3] add missing files --- src/commands/agent/list.ts | 113 +++++++++++++++++++++++++++++++++++++ src/commands/agent/show.ts | 47 +++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/commands/agent/list.ts create mode 100644 src/commands/agent/show.ts diff --git a/src/commands/agent/list.ts b/src/commands/agent/list.ts new file mode 100644 index 00000000..9df2fcf4 --- /dev/null +++ b/src/commands/agent/list.ts @@ -0,0 +1,113 @@ +/** + * List agents command + */ + +import chalk from "chalk"; +import { listAgents, type Agent } from "../../services/agentService.js"; +import { output, outputError } from "../../utils/output.js"; +import { formatTimeAgo } from "../../utils/time.js"; + +interface ListOptions { + full?: boolean; + name?: string; + search?: string; + public?: boolean; + private?: boolean; + output?: string; +} + +// Column widths (NAME is dynamic, takes remaining space) +const COL_VERSION = 14; +const COL_VISIBILITY = 10; +const COL_ID = 30; +const COL_CREATED = 10; +const FIXED_WIDTH = COL_VERSION + COL_VISIBILITY + COL_ID + COL_CREATED + 4; // 4 for spacing + +function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 1) + "…"; +} + +function printTable(agents: Agent[]): void { + if (agents.length === 0) { + console.log(chalk.dim("No agents found")); + return; + } + + const termWidth = process.stdout.columns || 120; + const nameWidth = Math.max(10, termWidth - FIXED_WIDTH); + + // Header + const header = + "NAME".padEnd(nameWidth) + + " " + + "VERSION".padEnd(COL_VERSION) + + " " + + "VISIBILITY".padEnd(COL_VISIBILITY) + + " " + + "ID".padEnd(COL_ID) + + " " + + "CREATED".padEnd(COL_CREATED); + console.log(chalk.bold(header)); + console.log(chalk.dim("─".repeat(Math.min(header.length, termWidth)))); + + for (const agent of agents) { + const name = truncate(agent.name, nameWidth).padEnd(nameWidth); + const version = truncate(agent.version, COL_VERSION).padEnd(COL_VERSION); + const visibility = (agent.is_public ? "public" : "private").padEnd( + COL_VISIBILITY, + ); + const visibilityColored = agent.is_public + ? chalk.green(visibility) + : chalk.dim(visibility); + const id = truncate(agent.id, COL_ID).padEnd(COL_ID); + const created = formatTimeAgo(agent.create_time_ms).padEnd(COL_CREATED); + + console.log( + `${name} ${version} ${visibilityColored} ${chalk.dim(id)} ${chalk.dim(created)}`, + ); + } + + console.log(); + console.log( + chalk.dim(`${agents.length} agent${agents.length !== 1 ? "s" : ""}`), + ); +} + +/** + * Keep only the most recently created agent for each name. + */ +function keepLatestPerName(agents: Agent[]): Agent[] { + const latestByName = new Map(); + for (const agent of agents) { + const existing = latestByName.get(agent.name); + if (!existing || agent.create_time_ms > existing.create_time_ms) { + latestByName.set(agent.name, agent); + } + } + return Array.from(latestByName.values()); +} + +export async function listAgentsCommand(options: ListOptions): Promise { + try { + const result = await listAgents({ + publicOnly: options.public, + privateOnly: options.private, + name: options.name, + search: options.search, + }); + + const agents = options.full + ? result.agents + : keepLatestPerName(result.agents); + + const format = options.output || "text"; + if (format !== "text") { + output(agents, { format, defaultFormat: "json" }); + } else { + printTable(agents); + } + } catch (error) { + outputError("Failed to list agents", error); + } +} diff --git a/src/commands/agent/show.ts b/src/commands/agent/show.ts new file mode 100644 index 00000000..62aa28d4 --- /dev/null +++ b/src/commands/agent/show.ts @@ -0,0 +1,47 @@ +/** + * Show agent details command + */ + +import { + getAgent, + listAgents, + type Agent, +} from "../../services/agentService.js"; +import { output, outputError } from "../../utils/output.js"; + +interface ShowOptions { + output?: string; +} + +/** + * Determine whether the input looks like an agent ID (starts with "agt_") + * vs. a name, then retrieve the corresponding agent. + */ +async function resolveAgent(idOrName: string): Promise { + if (idOrName.startsWith("agt_")) { + return getAgent(idOrName); + } + + // Look up by name — fetch all versions with this name and pick the latest. + 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}`); + } + + // Pick the most recently created version + matches.sort((a, b) => b.create_time_ms - a.create_time_ms); + return matches[0]; +} + +export async function showAgentCommand( + idOrName: string, + options: ShowOptions, +): Promise { + try { + const agent = await resolveAgent(idOrName); + output(agent, { format: options.output, defaultFormat: "text" }); + } catch (error) { + outputError("Failed to get agent", error); + } +} From d621c84056e2e5bbfe661fad56f20e52267c565c Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Mon, 30 Mar 2026 13:27:28 -0700 Subject: [PATCH 3/3] add missing test files --- tests/__tests__/commands/agent/list.test.ts | 140 ++++++++++++++++++++ tests/__tests__/commands/agent/show.test.ts | 116 ++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 tests/__tests__/commands/agent/list.test.ts create mode 100644 tests/__tests__/commands/agent/show.test.ts diff --git a/tests/__tests__/commands/agent/list.test.ts b/tests/__tests__/commands/agent/list.test.ts new file mode 100644 index 00000000..a09f1b0c --- /dev/null +++ b/tests/__tests__/commands/agent/list.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for agent list command + */ + +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockListAgents = jest.fn(); +jest.unstable_mockModule("@/services/agentService.js", () => ({ + listAgents: mockListAgents, +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, +})); + +const sampleAgents = [ + { + id: "agt_abc123", + name: "claude-code", + version: "2.0.65", + is_public: true, + create_time_ms: 2000, + }, + { + id: "agt_old456", + name: "claude-code", + version: "1.0.0", + is_public: true, + create_time_ms: 1000, + }, + { + id: "agt_xyz789", + name: "my-agent", + version: "0.1.0", + is_public: false, + create_time_ms: 3000, + }, +]; + +describe("listAgentsCommand", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockListAgents.mockReset(); + mockOutput.mockReset(); + mockOutputError.mockReset(); + }); + + it("should dedup to latest version per name by default", async () => { + mockListAgents.mockResolvedValue({ agents: sampleAgents }); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + // Capture console.log output to verify table was printed with deduped agents + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + await listAgentsCommand({}); + logSpy.mockRestore(); + + // Should not have been called with version filter (client-side dedup instead) + expect(mockListAgents).toHaveBeenCalledWith( + expect.not.objectContaining({ version: "latest" }), + ); + }); + + it("should show all versions when --full is set", async () => { + mockListAgents.mockResolvedValue({ agents: sampleAgents }); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + await listAgentsCommand({ full: true, output: "json" }); + + // All 3 agents should be output (no dedup) + expect(mockOutput).toHaveBeenCalledWith(sampleAgents, { + format: "json", + defaultFormat: "json", + }); + }); + + it("should keep only latest per name in JSON output", async () => { + mockListAgents.mockResolvedValue({ agents: sampleAgents }); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + await listAgentsCommand({ output: "json" }); + + const outputAgents = mockOutput.mock.calls[0][0] as typeof sampleAgents; + expect(outputAgents).toHaveLength(2); + expect(outputAgents.find((a) => a.name === "claude-code")?.version).toBe( + "2.0.65", + ); + expect(outputAgents.find((a) => a.name === "my-agent")?.version).toBe( + "0.1.0", + ); + }); + + it("should pass --public filter", async () => { + mockListAgents.mockResolvedValue({ agents: [] }); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + await listAgentsCommand({ public: true }); + + expect(mockListAgents).toHaveBeenCalledWith( + expect.objectContaining({ publicOnly: true }), + ); + }); + + it("should pass --private filter", async () => { + mockListAgents.mockResolvedValue({ agents: [] }); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + await listAgentsCommand({ private: true }); + + expect(mockListAgents).toHaveBeenCalledWith( + expect.objectContaining({ privateOnly: true }), + ); + }); + + it("should pass --name filter", async () => { + mockListAgents.mockResolvedValue({ agents: [] }); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + await listAgentsCommand({ name: "claude" }); + + expect(mockListAgents).toHaveBeenCalledWith( + expect.objectContaining({ name: "claude" }), + ); + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API Error"); + mockListAgents.mockRejectedValue(apiError); + + const { listAgentsCommand } = await import("@/commands/agent/list.js"); + await listAgentsCommand({}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to list agents", + apiError, + ); + }); +}); diff --git a/tests/__tests__/commands/agent/show.test.ts b/tests/__tests__/commands/agent/show.test.ts new file mode 100644 index 00000000..a015bd2a --- /dev/null +++ b/tests/__tests__/commands/agent/show.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for agent show command + */ + +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; + +const mockGetAgent = jest.fn(); +const mockListAgents = jest.fn(); +jest.unstable_mockModule("@/services/agentService.js", () => ({ + getAgent: mockGetAgent, + listAgents: mockListAgents, +})); + +const mockOutput = jest.fn(); +const mockOutputError = jest.fn(); +jest.unstable_mockModule("@/utils/output.js", () => ({ + output: mockOutput, + outputError: mockOutputError, +})); + +const sampleAgent = { + id: "agt_abc123", + name: "claude-code", + version: "2.0.65", + is_public: true, + create_time_ms: Date.now(), +}; + +describe("showAgentCommand", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should retrieve by ID when input starts with agt_", async () => { + mockGetAgent.mockResolvedValue(sampleAgent); + + const { showAgentCommand } = await import("@/commands/agent/show.js"); + await showAgentCommand("agt_abc123", {}); + + expect(mockGetAgent).toHaveBeenCalledWith("agt_abc123"); + expect(mockListAgents).not.toHaveBeenCalled(); + expect(mockOutput).toHaveBeenCalledWith(sampleAgent, { + format: undefined, + defaultFormat: "text", + }); + }); + + it("should look up by name and return latest version", async () => { + const older = { ...sampleAgent, id: "agt_old", version: "1.0.0", create_time_ms: 1000 }; + const newer = { ...sampleAgent, id: "agt_new", version: "2.0.0", create_time_ms: 2000 }; + mockListAgents.mockResolvedValue({ + agents: [older, newer], + }); + + const { showAgentCommand } = await import("@/commands/agent/show.js"); + await showAgentCommand("claude-code", {}); + + expect(mockGetAgent).not.toHaveBeenCalled(); + expect(mockListAgents).toHaveBeenCalledWith({ name: "claude-code" }); + expect(mockOutput).toHaveBeenCalledWith(newer, { + format: undefined, + defaultFormat: "text", + }); + }); + + it("should error when name not found", async () => { + mockListAgents.mockResolvedValue({ agents: [] }); + + const { showAgentCommand } = await import("@/commands/agent/show.js"); + await showAgentCommand("nonexistent", {}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to get agent", + expect.any(Error), + ); + }); + + it("should filter out partial name matches", async () => { + // API returns partial matches; we only want exact + const partial = { ...sampleAgent, name: "claude-code-extended" }; + mockListAgents.mockResolvedValue({ agents: [partial] }); + + const { showAgentCommand } = await import("@/commands/agent/show.js"); + await showAgentCommand("claude-code", {}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to get agent", + expect.any(Error), + ); + }); + + it("should output in requested format", async () => { + mockGetAgent.mockResolvedValue(sampleAgent); + + const { showAgentCommand } = await import("@/commands/agent/show.js"); + await showAgentCommand("agt_abc123", { output: "json" }); + + expect(mockOutput).toHaveBeenCalledWith(sampleAgent, { + format: "json", + defaultFormat: "text", + }); + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API Error"); + mockGetAgent.mockRejectedValue(apiError); + + const { showAgentCommand } = await import("@/commands/agent/show.js"); + await showAgentCommand("agt_abc123", {}); + + expect(mockOutputError).toHaveBeenCalledWith( + "Failed to get agent", + apiError, + ); + }); +});