Skip to content

Commit dc9743e

Browse files
alb-rlcursoragent
andauthored
feat: axon list with pagination and api-client upgrade (#184)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary Adds CLI support for listing active axons with cursor-style pagination (`limit`, `starting_after`, `has_more`), matching the updated backend contract. ## Changes - **Dependency:** Bump `@runloop/api-client` to `1.14.1` (includes `client.axons` and other API updates). - **Devbox tunnel:** Replace removed `createTunnel` with `enableTunnel` and construct the V2 tunnel URL from `tunnel_key` and port (required for the upgraded client to compile). - **New command:** `rli axon list` with `--limit`, `--starting-after`, and `-o/--output` (text default; json/yaml supported). Default `--limit 0` means fetch all pages when not using `--starting-after`. - **Service:** `listActiveAxons` passes query params via request `query` and reads optional `has_more` from the JSON response (forward-compatible until the published client types catch up). - **Docs:** README command section updated via `docs:commands`. ## Usage ```bash rli axon list rli axon list --limit 50 rli axon list --starting-after <last_axon_id> --limit 100 rli axon list -o json ``` ## Testing - `pnpm run build` — pass - `pnpm test` — unit/router tests pass; e2e fails without a valid `RUNLOOP_API_KEY` (pre-existing environment constraint) <!-- CURSOR_AGENT_PR_BODY_END --> [Slack Thread](https://runloophq.slack.com/archives/C0AH4DJ5HB8/p1774575256282429?thread_ts=1774575256.282429&cid=C0AH4DJ5HB8) <div><a href="https://cursor.com/agents/bc-ffbec2da-fab8-5f94-a065-a185ecd8f447"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-ffbec2da-fab8-5f94-a065-a185ecd8f447"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 8ce39d9 commit dc9743e

14 files changed

Lines changed: 508 additions & 271 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ rli mcp start # Start the MCP server
181181
rli mcp install # Install Runloop MCP server configurat...
182182
```
183183

184+
### Axon Commands
185+
186+
```bash
187+
rli axon list # List active axons
188+
```
189+
184190
### Scenario Commands (alias: `scn`)
185191

186192
```bash

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
"dependencies": {
7373
"@js-temporal/polyfill": "^0.5.1",
7474
"@modelcontextprotocol/sdk": "^1.26.0",
75-
"@runloop/api-client": "1.10.3",
75+
"@runloop/api-client": "1.16.0",
7676
"@types/express": "^5.0.6",
7777
"adm-zip": "^0.5.16",
7878
"chalk": "^5.6.2",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/axon/list.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* List active axons (beta)
3+
*/
4+
5+
import chalk from "chalk";
6+
import { formatTimeAgo } from "../../components/ResourceListView.js";
7+
import { listActiveAxons, type Axon } from "../../services/axonService.js";
8+
import { output, outputError, parseLimit } from "../../utils/output.js";
9+
10+
interface ListOptions {
11+
limit?: string;
12+
startingAfter?: string;
13+
output?: string;
14+
}
15+
16+
const PAGE_SIZE = 100;
17+
18+
function printTable(axons: Axon[]): void {
19+
if (axons.length === 0) {
20+
console.log(chalk.dim("No active axons found"));
21+
return;
22+
}
23+
24+
const COL_ID = 34;
25+
const COL_NAME = 28;
26+
const COL_CREATED = 12;
27+
28+
const header =
29+
"ID".padEnd(COL_ID) +
30+
" " +
31+
"NAME".padEnd(COL_NAME) +
32+
" " +
33+
"CREATED".padEnd(COL_CREATED);
34+
console.log(chalk.bold(header));
35+
console.log(chalk.dim("─".repeat(header.length)));
36+
37+
for (const axon of axons) {
38+
const id =
39+
axon.id.length > COL_ID ? axon.id.slice(0, COL_ID - 1) + "…" : axon.id;
40+
const nameRaw = axon.name ?? "";
41+
const name =
42+
nameRaw.length > COL_NAME
43+
? nameRaw.slice(0, COL_NAME - 1) + "…"
44+
: nameRaw;
45+
const created = formatTimeAgo(axon.created_at_ms);
46+
console.log(
47+
`${id.padEnd(COL_ID)} ${name.padEnd(COL_NAME)} ${created.padEnd(COL_CREATED)}`,
48+
);
49+
}
50+
51+
console.log();
52+
console.log(
53+
chalk.dim(`${axons.length} axon${axons.length !== 1 ? "s" : ""}`),
54+
);
55+
}
56+
57+
export async function listAxonsCommand(options: ListOptions): Promise<void> {
58+
try {
59+
const maxResults = parseLimit(options.limit);
60+
const format = options.output || "text";
61+
62+
let axons: Axon[];
63+
64+
if (options.startingAfter) {
65+
const pageLimit = maxResults === Infinity ? PAGE_SIZE : maxResults;
66+
const { axons: page, hasMore } = await listActiveAxons({
67+
limit: pageLimit,
68+
startingAfter: options.startingAfter,
69+
});
70+
axons = page;
71+
if (format === "text" && hasMore && axons.length > 0) {
72+
console.log(
73+
chalk.dim(
74+
"More results may be available; use --starting-after with the last ID to continue.",
75+
),
76+
);
77+
console.log();
78+
}
79+
} else {
80+
const all: Axon[] = [];
81+
let cursor: string | undefined;
82+
while (all.length < maxResults) {
83+
const remaining = maxResults - all.length;
84+
const pageLimit = Math.min(PAGE_SIZE, remaining);
85+
const { axons: page, hasMore } = await listActiveAxons({
86+
limit: pageLimit,
87+
startingAfter: cursor,
88+
});
89+
all.push(...page);
90+
if (!hasMore || page.length === 0) {
91+
break;
92+
}
93+
cursor = page[page.length - 1].id;
94+
}
95+
axons = all;
96+
}
97+
98+
if (format !== "text") {
99+
output(axons, { format, defaultFormat: "json" });
100+
} else {
101+
printTable(axons);
102+
}
103+
} catch (error) {
104+
outputError("Failed to list active axons", error);
105+
}
106+
}

src/commands/benchmark-job/logs.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ function buildScenarioOutcomeMap(
100100
const map = new Map<string, ScenarioOutcome>();
101101
for (const outcome of job.benchmark_outcomes || []) {
102102
for (const scenario of outcome.scenario_outcomes || []) {
103-
map.set(scenario.scenario_run_id, scenario);
103+
const runId = scenario.scenario_run_id;
104+
if (runId) {
105+
map.set(runId, scenario);
106+
}
104107
}
105108
}
106109
return map;

src/components/DevboxDetailPage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
type DetailSection,
1313
type ResourceOperation,
1414
} from "./ResourceDetailPage.js";
15-
import { getDevboxUrl } from "../utils/url.js";
15+
import { getDevboxUrl, getDevboxTunnelUrlPattern } from "../utils/url.js";
1616
import { colors } from "../utils/theme.js";
1717
import { formatTimeAgo } from "../utils/time.js";
1818
import { getMcpConfig } from "../services/mcpConfigService.js";
@@ -339,7 +339,7 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => {
339339
if (devbox.tunnel && devbox.tunnel.tunnel_key) {
340340
const tunnelKey = devbox.tunnel.tunnel_key;
341341
const authMode = devbox.tunnel.auth_mode;
342-
const tunnelUrl = `https://{port}-${tunnelKey}.tunnel.runloop.ai`;
342+
const tunnelUrl = getDevboxTunnelUrlPattern(tunnelKey);
343343

344344
detailFields.push({
345345
label: "Tunnel",
@@ -651,7 +651,7 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => {
651651
</Text>,
652652
);
653653

654-
const tunnelUrl = `https://{port}-${devbox.tunnel.tunnel_key}.tunnel.runloop.ai`;
654+
const tunnelUrl = getDevboxTunnelUrlPattern(devbox.tunnel.tunnel_key);
655655
lines.push(
656656
<Text key="tunnel-url" color={colors.success}>
657657
{" "}

src/services/axonService.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Axon service — active axons listing (beta API)
3+
*/
4+
import { getClient } from "../utils/client.js";
5+
import type { AxonView } from "@runloop/api-client/resources/axons/axons";
6+
import type { AxonsCursorIDPage } from "@runloop/api-client/pagination";
7+
8+
export type Axon = AxonView;
9+
10+
export interface ListActiveAxonsOptions {
11+
limit?: number;
12+
startingAfter?: string;
13+
}
14+
15+
export interface ListActiveAxonsResult {
16+
axons: Axon[];
17+
hasMore: boolean;
18+
}
19+
20+
/**
21+
* List active axons with optional cursor pagination (`limit`, `starting_after`).
22+
*/
23+
export async function listActiveAxons(
24+
options: ListActiveAxonsOptions,
25+
): Promise<ListActiveAxonsResult> {
26+
const client = getClient();
27+
28+
const query: {
29+
limit?: number;
30+
starting_after?: string;
31+
} = {};
32+
if (options.limit !== undefined) {
33+
query.limit = options.limit;
34+
}
35+
if (options.startingAfter) {
36+
query.starting_after = options.startingAfter;
37+
}
38+
39+
const page = (await client.axons.list(
40+
Object.keys(query).length > 0 ? query : undefined,
41+
)) as AxonsCursorIDPage<AxonView>;
42+
43+
const axons = page.axons || [];
44+
const hasMore = page.has_more || false;
45+
46+
return { axons, hasMore };
47+
}

src/services/devboxService.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Returns plain data objects with no SDK reference retention
44
*/
55
import { getClient } from "../utils/client.js";
6+
import { getTunnelBaseHost } from "../utils/url.js";
67
import type { Devbox } from "../store/devboxStore.js";
78
import type { DevboxesCursorIDPage } from "@runloop/api-client/pagination";
89
import type {
@@ -254,17 +255,18 @@ export async function createSSHKey(id: string): Promise<{
254255
}
255256

256257
/**
257-
* Create tunnel to devbox
258+
* Enable V2 HTTP tunnel on devbox and return the public URL for the given port.
258259
*/
259260
export async function createTunnel(
260261
id: string,
261262
port: number,
262263
): Promise<{ url: string }> {
263264
const client = getClient();
264-
const tunnel = await client.devboxes.createTunnel(id, { port });
265+
const tunnel = await client.devboxes.enableTunnel(id);
266+
const url = `https://${port}-${tunnel.tunnel_key}.${getTunnelBaseHost()}`;
265267

266268
return {
267-
url: String((tunnel as any).url || "").substring(0, 500),
269+
url: url.substring(0, 500),
268270
};
269271
}
270272

src/utils/commands.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,26 @@ export function createProgram(): Command {
10121012
await installMcpConfig();
10131013
});
10141014

1015+
// Axon commands (beta)
1016+
const axon = program.command("axon").description("Manage axons (beta)");
1017+
1018+
axon
1019+
.command("list")
1020+
.description("List active axons")
1021+
.option("--limit <n>", "Max axons to return (0 = unlimited)", "0")
1022+
.option(
1023+
"--starting-after <id>",
1024+
"Starting point for cursor pagination (axon ID)",
1025+
)
1026+
.option(
1027+
"-o, --output [format]",
1028+
"Output format: text|json|yaml (default: text)",
1029+
)
1030+
.action(async (options) => {
1031+
const { listAxonsCommand } = await import("../commands/axon/list.js");
1032+
await listAxonsCommand(options);
1033+
});
1034+
10151035
// Scenario commands
10161036
const scenario = program
10171037
.command("scenario")

src/utils/url.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,18 @@ export function getSettingsUrl(): string {
4242
const baseUrl = getBaseUrl();
4343
return `${baseUrl}/settings`;
4444
}
45+
46+
/**
47+
* Hostname for V2 devbox tunnel URLs (matches RUNLOOP_ENV / API host).
48+
*/
49+
export function getTunnelBaseHost(): string {
50+
const env = process.env.RUNLOOP_ENV?.toLowerCase();
51+
return env === "dev" ? "tunnel.runloop.pro" : "tunnel.runloop.ai";
52+
}
53+
54+
/**
55+
* Tunnel URL pattern with a literal `{port}` placeholder for display.
56+
*/
57+
export function getDevboxTunnelUrlPattern(tunnelKey: string): string {
58+
return `https://{port}-${tunnelKey}.${getTunnelBaseHost()}`;
59+
}

0 commit comments

Comments
 (0)