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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ rli mcp start # Start the MCP server
rli mcp install # Install Runloop MCP server configurat...
```

### Axon Commands

```bash
rli axon list # List active axons
```

### Scenario Commands (alias: `scn`)

```bash
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"dependencies": {
"@js-temporal/polyfill": "^0.5.1",
"@modelcontextprotocol/sdk": "^1.26.0",
"@runloop/api-client": "1.10.3",
"@runloop/api-client": "1.16.0",
"@types/express": "^5.0.6",
"adm-zip": "^0.5.16",
"chalk": "^5.6.2",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 106 additions & 0 deletions src/commands/axon/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* List active axons (beta)
*/

import chalk from "chalk";
import { formatTimeAgo } from "../../components/ResourceListView.js";
import { listActiveAxons, type Axon } from "../../services/axonService.js";
import { output, outputError, parseLimit } from "../../utils/output.js";

interface ListOptions {
limit?: string;
startingAfter?: string;
output?: string;
}

const PAGE_SIZE = 100;

function printTable(axons: Axon[]): void {
if (axons.length === 0) {
console.log(chalk.dim("No active axons found"));
return;
}

const COL_ID = 34;
const COL_NAME = 28;
const COL_CREATED = 12;

const header =
"ID".padEnd(COL_ID) +
" " +
"NAME".padEnd(COL_NAME) +
" " +
"CREATED".padEnd(COL_CREATED);
console.log(chalk.bold(header));
console.log(chalk.dim("─".repeat(header.length)));

for (const axon of axons) {
const id =
axon.id.length > COL_ID ? axon.id.slice(0, COL_ID - 1) + "…" : axon.id;
const nameRaw = axon.name ?? "";
const name =
nameRaw.length > COL_NAME
? nameRaw.slice(0, COL_NAME - 1) + "…"
: nameRaw;
const created = formatTimeAgo(axon.created_at_ms);
console.log(
`${id.padEnd(COL_ID)} ${name.padEnd(COL_NAME)} ${created.padEnd(COL_CREATED)}`,
);
}

console.log();
console.log(
chalk.dim(`${axons.length} axon${axons.length !== 1 ? "s" : ""}`),
);
}

export async function listAxonsCommand(options: ListOptions): Promise<void> {
try {
const maxResults = parseLimit(options.limit);
const format = options.output || "text";

let axons: Axon[];

if (options.startingAfter) {
const pageLimit = maxResults === Infinity ? PAGE_SIZE : maxResults;
const { axons: page, hasMore } = await listActiveAxons({
limit: pageLimit,
startingAfter: options.startingAfter,
});
axons = page;
if (format === "text" && hasMore && axons.length > 0) {
console.log(
chalk.dim(
"More results may be available; use --starting-after with the last ID to continue.",
),
);
console.log();
}
} else {
const all: Axon[] = [];
let cursor: string | undefined;
while (all.length < maxResults) {
const remaining = maxResults - all.length;
const pageLimit = Math.min(PAGE_SIZE, remaining);
const { axons: page, hasMore } = await listActiveAxons({
limit: pageLimit,
startingAfter: cursor,
});
all.push(...page);
if (!hasMore || page.length === 0) {
break;
}
cursor = page[page.length - 1].id;
}
axons = all;
}

if (format !== "text") {
output(axons, { format, defaultFormat: "json" });
} else {
printTable(axons);
}
} catch (error) {
outputError("Failed to list active axons", error);
}
}
5 changes: 4 additions & 1 deletion src/commands/benchmark-job/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ function buildScenarioOutcomeMap(
const map = new Map<string, ScenarioOutcome>();
for (const outcome of job.benchmark_outcomes || []) {
for (const scenario of outcome.scenario_outcomes || []) {
map.set(scenario.scenario_run_id, scenario);
const runId = scenario.scenario_run_id;
if (runId) {
map.set(runId, scenario);
}
}
}
return map;
Expand Down
6 changes: 3 additions & 3 deletions src/components/DevboxDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type DetailSection,
type ResourceOperation,
} from "./ResourceDetailPage.js";
import { getDevboxUrl } from "../utils/url.js";
import { getDevboxUrl, getDevboxTunnelUrlPattern } from "../utils/url.js";
import { colors } from "../utils/theme.js";
import { formatTimeAgo } from "../utils/time.js";
import { getMcpConfig } from "../services/mcpConfigService.js";
Expand Down Expand Up @@ -339,7 +339,7 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => {
if (devbox.tunnel && devbox.tunnel.tunnel_key) {
const tunnelKey = devbox.tunnel.tunnel_key;
const authMode = devbox.tunnel.auth_mode;
const tunnelUrl = `https://{port}-${tunnelKey}.tunnel.runloop.ai`;
const tunnelUrl = getDevboxTunnelUrlPattern(tunnelKey);

detailFields.push({
label: "Tunnel",
Expand Down Expand Up @@ -651,7 +651,7 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => {
</Text>,
);

const tunnelUrl = `https://{port}-${devbox.tunnel.tunnel_key}.tunnel.runloop.ai`;
const tunnelUrl = getDevboxTunnelUrlPattern(devbox.tunnel.tunnel_key);
lines.push(
<Text key="tunnel-url" color={colors.success}>
{" "}
Expand Down
47 changes: 47 additions & 0 deletions src/services/axonService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Axon service — active axons listing (beta API)
*/
import { getClient } from "../utils/client.js";
import type { AxonView } from "@runloop/api-client/resources/axons/axons";
import type { AxonsCursorIDPage } from "@runloop/api-client/pagination";

export type Axon = AxonView;

export interface ListActiveAxonsOptions {
limit?: number;
startingAfter?: string;
}

export interface ListActiveAxonsResult {
axons: Axon[];
hasMore: boolean;
}

/**
* List active axons with optional cursor pagination (`limit`, `starting_after`).
*/
export async function listActiveAxons(
options: ListActiveAxonsOptions,
): Promise<ListActiveAxonsResult> {
const client = getClient();

const query: {
limit?: number;
starting_after?: string;
} = {};
if (options.limit !== undefined) {
query.limit = options.limit;
}
if (options.startingAfter) {
query.starting_after = options.startingAfter;
}

const page = (await client.axons.list(
Object.keys(query).length > 0 ? query : undefined,
)) as AxonsCursorIDPage<AxonView>;

const axons = page.axons || [];
const hasMore = page.has_more || false;

return { axons, hasMore };
}
8 changes: 5 additions & 3 deletions src/services/devboxService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Returns plain data objects with no SDK reference retention
*/
import { getClient } from "../utils/client.js";
import { getTunnelBaseHost } from "../utils/url.js";
import type { Devbox } from "../store/devboxStore.js";
import type { DevboxesCursorIDPage } from "@runloop/api-client/pagination";
import type {
Expand Down Expand Up @@ -254,17 +255,18 @@ export async function createSSHKey(id: string): Promise<{
}

/**
* Create tunnel to devbox
* Enable V2 HTTP tunnel on devbox and return the public URL for the given port.
*/
export async function createTunnel(
id: string,
port: number,
): Promise<{ url: string }> {
const client = getClient();
const tunnel = await client.devboxes.createTunnel(id, { port });
const tunnel = await client.devboxes.enableTunnel(id);
const url = `https://${port}-${tunnel.tunnel_key}.${getTunnelBaseHost()}`;

return {
url: String((tunnel as any).url || "").substring(0, 500),
url: url.substring(0, 500),
};
}

Expand Down
20 changes: 20 additions & 0 deletions src/utils/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,26 @@ export function createProgram(): Command {
await installMcpConfig();
});

// Axon commands (beta)
const axon = program.command("axon").description("Manage axons (beta)");

axon
.command("list")
.description("List active axons")
.option("--limit <n>", "Max axons to return (0 = unlimited)", "0")
.option(
"--starting-after <id>",
"Starting point for cursor pagination (axon ID)",
)
.option(
"-o, --output [format]",
"Output format: text|json|yaml (default: text)",
)
.action(async (options) => {
const { listAxonsCommand } = await import("../commands/axon/list.js");
await listAxonsCommand(options);
});

// Scenario commands
const scenario = program
.command("scenario")
Expand Down
15 changes: 15 additions & 0 deletions src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,18 @@ export function getSettingsUrl(): string {
const baseUrl = getBaseUrl();
return `${baseUrl}/settings`;
}

/**
* Hostname for V2 devbox tunnel URLs (matches RUNLOOP_ENV / API host).
*/
export function getTunnelBaseHost(): string {
const env = process.env.RUNLOOP_ENV?.toLowerCase();
return env === "dev" ? "tunnel.runloop.pro" : "tunnel.runloop.ai";
}

/**
* Tunnel URL pattern with a literal `{port}` placeholder for display.
*/
export function getDevboxTunnelUrlPattern(tunnelKey: string): string {
return `https://{port}-${tunnelKey}.${getTunnelBaseHost()}`;
}
Loading
Loading