Skip to content
Merged
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
113 changes: 113 additions & 0 deletions src/commands/agent/list.ts
Original file line number Diff line number Diff line change
@@ -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) + "…";

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.

should this be maxLen -1 - "...".length?

}

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();

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.

is this just for the newline?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep!

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<string, Agent>();
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<void> {
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);
}
}
47 changes: 47 additions & 0 deletions src/commands/agent/show.ts
Original file line number Diff line number Diff line change
@@ -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<Agent> {
if (idOrName.startsWith("agt_")) {
return getAgent(idOrName);
}

// Look up by name — fetch all versions with this name and pick the latest.

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.

This isn't a problem with this code, but reading through this makes me wonder if we should add a backend endpoint for this purpose

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I considered this as well. At some point this might make sense, especially if we have lots of versions for public agents. (eg, re-publish every week, across 100s of agents?). It depends on how often this 'list' feature gets used; it might be fairly rare, in which case it doesn't much matter?

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<void> {
try {
const agent = await resolveAgent(idOrName);
output(agent, { format: options.output, defaultFormat: "text" });
} catch (error) {
outputError("Failed to get agent", error);
}
}
6 changes: 6 additions & 0 deletions src/services/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface ListAgentsOptions {
privateOnly?: boolean;
name?: string;
search?: string;
version?: string;
}

export interface ListAgentsResult {
Expand All @@ -37,6 +38,7 @@ export async function listAgents(
is_public?: boolean;
name?: string;
search?: string;
version?: string;
} = {
limit: options.limit || 50,
};
Expand All @@ -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[] = [];

Expand Down
35 changes: 35 additions & 0 deletions src/utils/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>", "Filter by name (partial match)")
.option("--search <query>", "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 <id-or-name>")
.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 })
Expand Down
47 changes: 35 additions & 12 deletions src/utils/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading
Loading