diff --git a/CLAUDE_SETUP.md b/CLAUDE_SETUP.md index bbcf7f28..a2768969 100644 --- a/CLAUDE_SETUP.md +++ b/CLAUDE_SETUP.md @@ -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: diff --git a/MCP_COMMANDS.md b/MCP_COMMANDS.md index 80d9cdbe..468e6518 100644 --- a/MCP_COMMANDS.md +++ b/MCP_COMMANDS.md @@ -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: diff --git a/MCP_README.md b/MCP_README.md index 4aa5c1fe..6db8a014 100644 --- a/MCP_README.md +++ b/MCP_README.md @@ -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 diff --git a/README.md b/README.md index 94134f1e..63248fa6 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ pnpm add -g @runloop/rl-cli ## Setup -Configure your API key: +### API key ```bash export RUNLOOP_API_KEY=your_api_key_here @@ -54,6 +54,30 @@ 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.:443`): + +| Service | Host | +|----------|------| +| API | `https://api.` | +| Platform | `https://platform.` | +| SSH | `ssh.:443` | +| Tunnels | `tunnel.` (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) diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 3299183f..e135d669 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -12,6 +12,7 @@ import { ConfirmationPrompt } from "./ConfirmationPrompt.js"; 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"; @@ -661,8 +662,7 @@ export const DevboxActionsMenu = ({ 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 diff --git a/src/components/HomeBaseUrlText.tsx b/src/components/HomeBaseUrlText.tsx new file mode 100644 index 00000000..b1cd74d7 --- /dev/null +++ b/src/components/HomeBaseUrlText.tsx @@ -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.` when set) for home footer. */ +export function HomeBaseUrlText() { + const apiBase = React.useMemo(() => baseUrl(), []); + return ( + + {"\n"} + Base URL: {apiBase} + + ); +} diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 14df4a8e..0b7cdcda 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -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; @@ -284,6 +285,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} {navTips} + + + ); } @@ -332,6 +336,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} {navTips} + + + ); } @@ -390,6 +397,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} {navTips} + + + ); } @@ -494,6 +504,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { {navTips} + + + ); }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d5755cc2..5c75c723 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 { @@ -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(); @@ -66,7 +55,7 @@ function getClient(): Runloop { ); } - const baseURL = getBaseUrl(); + const baseURL = getApiBaseUrl(); return new Runloop({ bearerToken: config.apiKey, diff --git a/src/utils/client.ts b/src/utils/client.ts index b5003d2b..04f12fcf 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -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(); @@ -28,7 +11,7 @@ export function getClient(): Runloop { ); } - const baseURL = getBaseUrl(); + const baseURL = getApiBaseUrl(); return new Runloop({ bearerToken: config.apiKey, diff --git a/src/utils/config.ts b/src/utils/config.ts index 80dac7e5..40defa6b 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -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.`. + * - 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}.`). */ +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 { diff --git a/src/utils/ssh.ts b/src/utils/ssh.ts index 641997e2..af5ffaa2 100644 --- a/src/utils/ssh.ts +++ b/src/utils/ssh.ts @@ -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); @@ -148,11 +149,10 @@ export async function waitForReady( } /** - * Get SSH URL based on environment + * Get SSH TLS proxy target (`ssh.: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(); } /** diff --git a/src/utils/url.ts b/src/utils/url.ts index d856f190..612db1e9 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -2,21 +2,14 @@ * Utility functions for generating URLs */ +import { platformBaseUrl, tunnelBaseHostname } from "./config.js"; + /** - * Get the base URL for the Runloop platform based on environment - * - dev: https://platform.runloop.pro - * - prod or unset: https://platform.runloop.ai (default) + * Web platform base URL (browser). With `RUNLOOP_BASE_DOMAIN=example.com`, + * uses `https://platform.example.com`; otherwise `RUNLOOP_ENV` picks .pro vs .ai. */ export function getBaseUrl(): string { - const env = process.env.RUNLOOP_ENV?.toLowerCase(); - - switch (env) { - case "dev": - return "https://platform.runloop.pro"; - case "prod": - default: - return "https://platform.runloop.ai"; - } + return platformBaseUrl(); } /** @@ -44,11 +37,10 @@ export function getSettingsUrl(): string { } /** - * Hostname for V2 devbox tunnel URLs (matches RUNLOOP_ENV / API host). + * Hostname for V2 devbox tunnel URLs (`tunnel.` when set). */ export function getTunnelBaseHost(): string { - const env = process.env.RUNLOOP_ENV?.toLowerCase(); - return env === "dev" ? "tunnel.runloop.pro" : "tunnel.runloop.ai"; + return tunnelBaseHostname(); } /**