Skip to content
Closed
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
20 changes: 20 additions & 0 deletions CLAUDE_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,26 @@ If you want to connect to Runloop's development environment:
}
```

### Custom deployment domain

To use a non-default Runloop deployment, set `RUNLOOP_BASE_DOMAIN` to a **bare domain suffix** (e.g. customer vanity domain). The CLI builds `api.`, `platform.`, `ssh.`, and `tunnel.` hostnames from it; invalid or full-URL values are ignored.

```json
{
"mcpServers": {
"runloop": {
"command": "rli",
"args": ["mcp", "start"],
"env": {
"RUNLOOP_BASE_DOMAIN": "example.com"
}
}
}
}
```

See the repository [README](README.md) **Setup → Custom domain (`RUNLOOP_BASE_DOMAIN`)** for validation and the hostname table.

### Using a Specific Path

If `rli` isn't in your PATH, you can specify the full path:
Expand Down
18 changes: 18 additions & 0 deletions MCP_COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@ To use the development environment:
}
```

To point MCP at a **custom deployment** (bare domain suffix; overrides hostnames implied by `RUNLOOP_ENV`):

```json
{
"mcpServers": {
"runloop": {
"command": "rli",
"args": ["mcp", "start"],
"env": {
"RUNLOOP_BASE_DOMAIN": "example.com"
}
}
}
}
```

See [README.md](README.md) (Setup → Custom domain) for validation rules and the `api.` / `platform.` / `ssh.` / `tunnel.` prefix behavior.

## Available Tools

Once configured, Claude can use these tools:
Expand Down
7 changes: 5 additions & 2 deletions MCP_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,11 @@ Claude will use the MCP tools to interact with your Runloop account and provide

## Environment Variables

- `RUNLOOP_ENV` - Set to `dev` for development environment, `prod` (or leave unset) for production
- API key is read from the CLI configuration (~/.config/rli/config.json)
- `RUNLOOP_API_KEY` — Required unless the key is stored in the CLI config under `~/.runloop`.
- `RUNLOOP_ENV` — `prod` or unset uses production (`https://api.runloop.ai`).
- `RUNLOOP_BASE_DOMAIN` — Optional bare domain suffix (e.g. `runloop.ai`). The MCP server builds `api.`, `platform.`, `ssh.`, and `tunnel.` hostnames from it the same way as the CLI. Full URLs and invalid values are ignored; see [README](README.md) **Setup → Custom domain (`RUNLOOP_BASE_DOMAIN`)**.

See the main [README](README.md) **Setup → Custom domain (`RUNLOOP_BASE_DOMAIN`)** for details and TUI behavior.

## Troubleshooting

Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,38 @@ pnpm add -g @runloop/rl-cli

## Setup

Configure your API key:
### API key

```bash
export RUNLOOP_API_KEY=your_api_key_here
```

Get your API key from [https://runloop.ai/settings](https://runloop.ai/settings)

### Custom domain (`RUNLOOP_BASE_DOMAIN`, optional)

The CLI and MCP server use the Runloop HTTP API. By default:

- **`RUNLOOP_ENV`** — `prod` or unset uses `https://api.runloop.ai` and `dev` uses `https://api.runloop.pro`.
- **`RUNLOOP_BASE_DOMAIN`** — Optional **bare domain suffix** only: e.g. `runloop.ai`, `runloop.pro`, or `example.com`. Do **not** include a scheme (`https://`), path, port, or leading `api.` label. Leading/trailing whitespace is trimmed; the value is treated case-insensitively.

When set and valid, the CLI and MCP use these hosts (all HTTPS on 443 except SSH, which uses TLS to `ssh.<suffix>:443`):

| Service | Host |
|----------|------|
| API | `https://api.<suffix>` |
| Platform | `https://platform.<suffix>` |
| SSH | `ssh.<suffix>:443` |
| Tunnels | `tunnel.<suffix>` (hostname for tunnel URLs) |

If the value is empty, contains `://`, `/`, whitespace, `:`, or is not a valid multi-label hostname, it is **ignored** and **`RUNLOOP_ENV`** defaults apply (same as unset).

Example:

```bash
export RUNLOOP_BASE_DOMAIN=example.com
```

## Usage

### TUI (Interactive Mode)
Expand Down
4 changes: 2 additions & 2 deletions src/components/DevboxActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { colors } from "../utils/theme.js";
import { openInBrowser } from "../utils/browser.js";
import { copyToClipboard } from "../utils/clipboard.js";
import { sshGatewayHostname } from "../utils/config.js";
import { useViewportHeight } from "../hooks/useViewportHeight.js";
import { useNavigation } from "../store/navigationStore.js";
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
Expand Down Expand Up @@ -490,10 +491,10 @@
input === "o" &&
operationResult &&
typeof operationResult === "object" &&
(operationResult as any).__customRender === "tunnel"

Check warning on line 494 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
) {
// Open tunnel URL in browser
const tunnelUrl = (operationResult as any).__tunnelUrl;

Check warning on line 497 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
if (tunnelUrl) {
openInBrowser(tunnelUrl);
setCopyStatus("Opened in browser!");
Expand All @@ -503,46 +504,46 @@
(key.upArrow || input === "k") &&
operationResult &&
typeof operationResult === "object" &&
(operationResult as any).__customRender === "exec"

Check warning on line 507 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
) {
setExecScroll(Math.max(0, execScroll - 1));
} else if (
(key.downArrow || input === "j") &&
operationResult &&
typeof operationResult === "object" &&
(operationResult as any).__customRender === "exec"

Check warning on line 514 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
) {
setExecScroll(execScroll + 1);
} else if (
key.pageUp &&
operationResult &&
typeof operationResult === "object" &&
(operationResult as any).__customRender === "exec"

Check warning on line 521 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
) {
setExecScroll(Math.max(0, execScroll - 10));
} else if (
key.pageDown &&
operationResult &&
typeof operationResult === "object" &&
(operationResult as any).__customRender === "exec"

Check warning on line 528 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
) {
setExecScroll(execScroll + 10);
} else if (
input === "g" &&
operationResult &&
typeof operationResult === "object" &&
(operationResult as any).__customRender === "exec"

Check warning on line 535 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
) {
setExecScroll(0);
} else if (
input === "G" &&
operationResult &&
typeof operationResult === "object" &&
(operationResult as any).__customRender === "exec"

Check warning on line 542 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
) {
const lines = [
...((operationResult as any).stdout || "").split("\n"),

Check warning on line 545 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
...((operationResult as any).stderr || "").split("\n"),

Check warning on line 546 in src/components/DevboxActionsMenu.tsx

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
];
const maxScroll = Math.max(
0,
Expand Down Expand Up @@ -661,8 +662,7 @@

const sshUser =
devbox.launch_parameters?.user_parameters?.username || "user";
const env = process.env.RUNLOOP_ENV?.toLowerCase();
const sshHost = env === "dev" ? "ssh.runloop.pro" : "ssh.runloop.ai";
const sshHost = sshGatewayHostname();
// macOS openssl doesn't support -verify_quiet, use compatible flags
// servername should be %h (target hostname) - SSH will replace %h with the actual hostname from the SSH command
// This matches the reference implementation where servername is the target hostname
Expand Down
15 changes: 15 additions & 0 deletions src/components/HomeBaseUrlText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from "react";
import { Text } from "ink";
import { baseUrl } from "../utils/config.js";
import { colors } from "../utils/theme.js";

/** Compact API origin (`https://api.<RUNLOOP_BASE_DOMAIN>` when set) for home footer. */
export function HomeBaseUrlText() {
const apiBase = React.useMemo(() => baseUrl(), []);
return (
<Text color={colors.textDim} dimColor>
{"\n"}
Base URL: {apiBase}
</Text>
);
}
13 changes: 13 additions & 0 deletions src/components/MainMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useVerticalLayout } from "../hooks/useVerticalLayout.js";
import { useBetaFeatures } from "../store/betaFeatureStore.js";
import type { BetaFeature } from "../store/betaFeatureStore.js";
import { useMenuStore } from "../store/menuStore.js";
import { HomeBaseUrlText } from "./HomeBaseUrlText.js";

interface MenuItem {
key: string;
Expand Down Expand Up @@ -284,6 +285,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => {
})}
</Box>
{navTips}
<Box paddingX={2} flexShrink={0}>
<HomeBaseUrlText />
</Box>
</Box>
);
}
Expand Down Expand Up @@ -332,6 +336,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => {
})}
</Box>
{navTips}
<Box paddingX={2} flexShrink={0}>
<HomeBaseUrlText />
</Box>
</Box>
);
}
Expand Down Expand Up @@ -390,6 +397,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => {
})}
</Box>
{navTips}
<Box paddingX={2} flexShrink={0}>
<HomeBaseUrlText />
</Box>
</Box>
);
}
Expand Down Expand Up @@ -494,6 +504,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => {
</Box>

{navTips}
<Box paddingX={2} flexShrink={0}>
<HomeBaseUrlText />
</Box>
</Box>
);
};
15 changes: 2 additions & 13 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Runloop from "@runloop/api-client";
import { VERSION } from "@runloop/api-client/version.js";
import Conf from "conf";
import { processUtils } from "../utils/processUtils.js";
import { baseUrl as getApiBaseUrl } from "../utils/config.js";

// Client configuration
interface Config {
Expand Down Expand Up @@ -45,18 +46,6 @@ function getConfig(): Config {
}
}

function getBaseUrl(): string {
const env = process.env.RUNLOOP_ENV?.toLowerCase();

switch (env) {
case "dev":
return "https://api.runloop.pro";
case "prod":
default:
return "https://api.runloop.ai";
}
}

function getClient(): Runloop {
const config = getConfig();

Expand All @@ -66,7 +55,7 @@ function getClient(): Runloop {
);
}

const baseURL = getBaseUrl();
const baseURL = getApiBaseUrl();

return new Runloop({
bearerToken: config.apiKey,
Expand Down
23 changes: 3 additions & 20 deletions src/utils/client.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,6 @@
import Runloop from "@runloop/api-client";
import { Runloop } from "@runloop/api-client";
import { VERSION } from "@runloop/api-client/version.js";
import { getConfig } from "./config.js";

/**
* Get the base URL based on RUNLOOP_ENV environment variable
* - dev: https://api.runloop.pro
* - prod or unset: https://api.runloop.ai (default)
*/
function getBaseUrl(): string {
const env = process.env.RUNLOOP_ENV?.toLowerCase();

switch (env) {
case "dev":
return "https://api.runloop.pro";
case "prod":
default:
return "https://api.runloop.ai";
}
}
import { getConfig, baseUrl as getApiBaseUrl } from "./config.js";

export function getClient(): Runloop {
const config = getConfig();
Expand All @@ -28,7 +11,7 @@ export function getClient(): Runloop {
);
}

const baseURL = getBaseUrl();
const baseURL = getApiBaseUrl();

return new Runloop({
bearerToken: config.apiKey,
Expand Down
88 changes: 85 additions & 3 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,98 @@ export function clearConfig(): void {
config.clear();
}

/**
* Bare domain suffix from `RUNLOOP_BASE_DOMAIN`, e.g. `runloop.ai` or `example.com`.
* Full URLs, `api.*` hostnames, paths, ports, or invalid hostnames → null (use RUNLOOP_ENV).
*/
function runloopBaseDomainOrNull(): string | null {
const raw = process.env.RUNLOOP_BASE_DOMAIN?.trim();
if (!raw) return null;
if (/:\/\//.test(raw) || /\s/.test(raw) || raw.includes("/")) {
return null;
}
if (raw.includes(":")) {
return null;
}
const domain = raw.toLowerCase();
if (!isValidBareDomain(domain)) {
return null;
}
return domain;
}

function isValidBareDomain(domain: string): boolean {
if (domain.length === 0 || domain.length > 253) return false;
if (domain.startsWith(".") || domain.endsWith(".")) return false;
if (!domain.includes(".")) return false;
const labels = domain.split(".");
for (const label of labels) {
if (label.length === 0 || label.length > 63) return false;
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label)) return false;
}
return true;
}

function prefixedHost(prefix: string, domain: string): string {
return `${prefix}.${domain}`;
}

/**
* HTTP base URL for the Runloop API (used by the CLI client and MCP).
*
* - If `RUNLOOP_BASE_DOMAIN` is a valid bare domain, uses `https://api.<domain>`.
* - Else `RUNLOOP_ENV=dev` → https://api.runloop.pro; otherwise production.
*/
export function baseUrl(): string {
const domain = runloopBaseDomainOrNull();
if (domain) {
return `https://${prefixedHost("api", domain)}`;
}
return process.env.RUNLOOP_ENV === "dev"
? "https://api.runloop.pro"
: "https://api.runloop.ai";
}

export function sshUrl(): string {
/**
* Web platform origin for deep links (settings, devbox pages in the browser).
*/
export function platformBaseUrl(): string {
const domain = runloopBaseDomainOrNull();
if (domain) {
return `https://${prefixedHost("platform", domain)}`;
}
return process.env.RUNLOOP_ENV === "dev"
? "https://platform.runloop.pro"
: "https://platform.runloop.ai";
}

/** Hostname for devbox tunnel URLs (`{port}-{key}.<host>`). */
export function tunnelBaseHostname(): string {
const domain = runloopBaseDomainOrNull();
if (domain) {
return prefixedHost("tunnel", domain);
}
return process.env.RUNLOOP_ENV === "dev"
? "tunnel.runloop.pro"
: "tunnel.runloop.ai";
}

/** SSH gateway hostname (TLS/SNI), without port. */
export function sshGatewayHostname(): string {
const domain = runloopBaseDomainOrNull();
if (domain) {
return prefixedHost("ssh", domain);
}
return process.env.RUNLOOP_ENV === "dev"
? "ssh.runloop.pro:443"
: "ssh.runloop.ai:443";
? "ssh.runloop.pro"
: "ssh.runloop.ai";
}

/**
* `host:443` for `openssl s_client -connect` (SSH over HTTPS).
*/
export function sshUrl(): string {
return `${sshGatewayHostname()}:443`;
}

export function getCacheDir(): string {
Expand Down
6 changes: 3 additions & 3 deletions src/utils/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { homedir } from "os";
import { getClient } from "./client.js";
import { cliStatus } from "./cliStatus.js";
import { processUtils } from "./processUtils.js";
import { sshUrl as sshTlsConnectEndpoint } from "./config.js";

const execAsync = promisify(exec);

Expand Down Expand Up @@ -148,11 +149,10 @@ export async function waitForReady(
}

/**
* Get SSH URL based on environment
* Get SSH TLS proxy target (`ssh.<domain>:443`) from `RUNLOOP_BASE_DOMAIN` or `RUNLOOP_ENV`.
*/
export function getSSHUrl(): string {
const env = processUtils.env.RUNLOOP_ENV?.toLowerCase();
return env === "dev" ? "ssh.runloop.pro:443" : "ssh.runloop.ai:443";
return sshTlsConnectEndpoint();
}

/**
Expand Down
Loading
Loading