diff --git a/apps/cli/README.md b/apps/cli/README.md index db33a264a2..d440536440 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -101,6 +101,50 @@ In non-interactive mode: - Followup questions show a 60-second timeout, then auto-select the first suggestion - Typing any key cancels the timeout and allows manual input +### Roo Code Cloud Authentication + +To use Roo Code Cloud features (like the provider proxy), you need to authenticate: + +```bash +# Log in to Roo Code Cloud (opens browser) +roo auth login + +# Check authentication status +roo auth status + +# Log out +roo auth logout +``` + +The `auth login` command: + +1. Opens your browser to authenticate with Roo Code Cloud +2. Receives a secure token via localhost callback +3. Stores the token in `~/.config/roo/credentials.json` + +Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when your token expires. + +**Authentication Flow:** + +``` +┌──────┐ ┌─────────┐ ┌───────────────┐ +│ CLI │ │ Browser │ │ Roo Code Cloud│ +└──┬───┘ └────┬────┘ └───────┬───────┘ + │ │ │ + │ Open auth URL │ │ + │─────────────────>│ │ + │ │ │ + │ │ Authenticate │ + │ │─────────────────────>│ + │ │ │ + │ │<─────────────────────│ + │ │ Token via callback │ + │<─────────────────│ │ + │ │ │ + │ Store token │ │ + │ │ │ +``` + ## Options | Option | Description | Default | @@ -108,7 +152,6 @@ In non-interactive mode: | `[workspace]` | Workspace path to operate in (positional argument) | Current directory | | `-P, --prompt ` | The prompt/task to execute (optional in TUI mode) | None | | `-e, --extension ` | Path to the extension bundle directory | Auto-detected | -| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` | | `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | | `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` | | `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` | @@ -120,7 +163,13 @@ In non-interactive mode: | `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | | `--no-tui` | Disable TUI, use plain text output | `false` | -By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information. +## Auth Commands + +| Command | Description | +| ----------------- | ---------------------------------- | +| `roo auth login` | Authenticate with Roo Code Cloud | +| `roo auth logout` | Clear stored authentication token | +| `roo auth status` | Show current authentication status | ## Environment Variables @@ -134,6 +183,12 @@ The CLI will look for API keys in environment variables if not provided via `--a | google/gemini | `GOOGLE_API_KEY` | | ... | ... | +**Authentication Environment Variables:** + +| Variable | Description | +| ----------------- | -------------------------------------------------------------------- | +| `ROO_WEB_APP_URL` | Override the Roo Code Cloud URL (default: `https://app.roocode.com`) | + ## Architecture ``` diff --git a/apps/cli/package.json b/apps/cli/package.json index 61b79fbc0f..968624b73c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -24,11 +24,13 @@ "@roo-code/core": "workspace:^", "@roo-code/types": "workspace:^", "@roo-code/vscode-shim": "workspace:^", + "@trpc/client": "^11.8.1", "@vscode/ripgrep": "^1.15.9", "commander": "^12.1.0", "fuzzysort": "^3.1.0", "ink": "^6.6.0", "react": "^19.1.0", + "superjson": "^2.2.6", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/cli/src/__tests__/extension-host.integration.test.ts b/apps/cli/src/__tests__/extension-host.integration.test.ts index 158438decb..f49b6ef01d 100644 --- a/apps/cli/src/__tests__/extension-host.integration.test.ts +++ b/apps/cli/src/__tests__/extension-host.integration.test.ts @@ -85,12 +85,12 @@ describe.skipIf(!hasApiKey || !hasExtension)( it("should complete end-to-end task execution with proper lifecycle", async () => { host = new ExtensionHost({ mode: "code", - apiProvider: "openrouter", + user: null, + provider: "openrouter", apiKey: OPENROUTER_API_KEY!, model: "anthropic/claude-haiku-4.5", // Use fast, cheap model for tests. workspacePath, extensionPath: extensionPath!, - quiet: true, }) // Test activation diff --git a/apps/cli/src/__tests__/extension-host.test.ts b/apps/cli/src/__tests__/extension-host.test.ts index 2d25a0712b..5bb131e359 100644 --- a/apps/cli/src/__tests__/extension-host.test.ts +++ b/apps/cli/src/__tests__/extension-host.test.ts @@ -5,9 +5,10 @@ import fs from "fs" import os from "os" import path from "path" -import type { ProviderName, WebviewMessage } from "@roo-code/types" +import type { WebviewMessage } from "@roo-code/types" -import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js" +import type { SupportedProvider } from "../types.js" +import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js" vi.mock("@roo-code/vscode-shim", () => ({ createVSCodeAPI: vi.fn(() => ({ @@ -21,13 +22,14 @@ vi.mock("@roo-code/vscode-shim", () => ({ */ function createTestHost({ mode = "code", - apiProvider = "openrouter", + provider = "openrouter", model = "test-model", ...options }: Partial = {}): ExtensionHost { return new ExtensionHost({ mode, - apiProvider, + user: null, + provider, model, workspacePath: "/test/workspace", extensionPath: "/test/extension", @@ -77,10 +79,9 @@ describe("ExtensionHost", () => { mode: "code", workspacePath: "/my/workspace", extensionPath: "/my/extension", - verbose: true, - quiet: true, + user: null, apiKey: "test-key", - apiProvider: "openrouter", + provider: "openrouter", model: "test-model", } @@ -134,93 +135,7 @@ describe("ExtensionHost", () => { "oai-model", { apiProvider: "openai-native", openAiNativeApiKey: "oai-key", apiModelId: "oai-model" }, ], - [ - "openai", - "oai-key", - "oai-model", - { apiProvider: "openai", openAiApiKey: "oai-key", openAiModelId: "oai-model" }, - ], - [ - "mistral", - "mis-key", - "mis-model", - { apiProvider: "mistral", mistralApiKey: "mis-key", apiModelId: "mis-model" }, - ], - [ - "deepseek", - "ds-key", - "ds-model", - { apiProvider: "deepseek", deepSeekApiKey: "ds-key", apiModelId: "ds-model" }, - ], - ["xai", "xai-key", "xai-model", { apiProvider: "xai", xaiApiKey: "xai-key", apiModelId: "xai-model" }], - [ - "groq", - "groq-key", - "groq-model", - { apiProvider: "groq", groqApiKey: "groq-key", apiModelId: "groq-model" }, - ], - [ - "fireworks", - "fw-key", - "fw-model", - { apiProvider: "fireworks", fireworksApiKey: "fw-key", apiModelId: "fw-model" }, - ], - [ - "cerebras", - "cer-key", - "cer-model", - { apiProvider: "cerebras", cerebrasApiKey: "cer-key", apiModelId: "cer-model" }, - ], - [ - "sambanova", - "sn-key", - "sn-model", - { apiProvider: "sambanova", sambaNovaApiKey: "sn-key", apiModelId: "sn-model" }, - ], - [ - "ollama", - "oll-key", - "oll-model", - { apiProvider: "ollama", ollamaApiKey: "oll-key", ollamaModelId: "oll-model" }, - ], - ["lmstudio", undefined, "lm-model", { apiProvider: "lmstudio", lmStudioModelId: "lm-model" }], - [ - "litellm", - "lite-key", - "lite-model", - { apiProvider: "litellm", litellmApiKey: "lite-key", litellmModelId: "lite-model" }, - ], - [ - "huggingface", - "hf-key", - "hf-model", - { apiProvider: "huggingface", huggingFaceApiKey: "hf-key", huggingFaceModelId: "hf-model" }, - ], - ["chutes", "ch-key", "ch-model", { apiProvider: "chutes", chutesApiKey: "ch-key", apiModelId: "ch-model" }], - [ - "featherless", - "fl-key", - "fl-model", - { apiProvider: "featherless", featherlessApiKey: "fl-key", apiModelId: "fl-model" }, - ], - [ - "unbound", - "ub-key", - "ub-model", - { apiProvider: "unbound", unboundApiKey: "ub-key", unboundModelId: "ub-model" }, - ], - [ - "requesty", - "req-key", - "req-model", - { apiProvider: "requesty", requestyApiKey: "req-key", requestyModelId: "req-model" }, - ], - [ - "deepinfra", - "di-key", - "di-model", - { apiProvider: "deepinfra", deepInfraApiKey: "di-key", deepInfraModelId: "di-model" }, - ], + [ "vercel-ai-gateway", "vai-key", @@ -231,35 +146,9 @@ describe("ExtensionHost", () => { vercelAiGatewayModelId: "vai-model", }, ], - ["zai", "zai-key", "zai-model", { apiProvider: "zai", zaiApiKey: "zai-key", apiModelId: "zai-model" }], - [ - "baseten", - "bt-key", - "bt-model", - { apiProvider: "baseten", basetenApiKey: "bt-key", apiModelId: "bt-model" }, - ], - ["doubao", "db-key", "db-model", { apiProvider: "doubao", doubaoApiKey: "db-key", apiModelId: "db-model" }], - [ - "moonshot", - "ms-key", - "ms-model", - { apiProvider: "moonshot", moonshotApiKey: "ms-key", apiModelId: "ms-model" }, - ], - [ - "minimax", - "mm-key", - "mm-model", - { apiProvider: "minimax", minimaxApiKey: "mm-key", apiModelId: "mm-model" }, - ], - [ - "io-intelligence", - "io-key", - "io-model", - { apiProvider: "io-intelligence", ioIntelligenceApiKey: "io-key", ioIntelligenceModelId: "io-model" }, - ], ])("should configure %s provider correctly", (provider, apiKey, model, expected) => { const host = createTestHost({ - apiProvider: provider as ProviderName, + provider: provider as SupportedProvider, apiKey, model, }) @@ -282,7 +171,7 @@ describe("ExtensionHost", () => { it("should handle missing apiKey gracefully", () => { const host = createTestHost({ - apiProvider: "anthropic", + provider: "anthropic", model: "test-model", }) @@ -292,20 +181,6 @@ describe("ExtensionHost", () => { expect(config.apiKey).toBeUndefined() expect(config.apiModelId).toBe("test-model") }) - - it("should use default config for unknown providers", () => { - const host = createTestHost({ - apiProvider: "unknown-provider" as ProviderName, - apiKey: "test-key", - model: "test-model", - }) - - const config = callPrivate>(host, "buildApiConfiguration") - - expect(config.apiProvider).toBe("unknown-provider") - expect(config.apiKey).toBe("test-key") - expect(config.apiModelId).toBe("test-model") - }) }) describe("webview provider registration", () => { @@ -946,17 +821,8 @@ describe("ExtensionHost", () => { describe("quiet mode", () => { describe("setupQuietMode", () => { - it("should not modify console when quiet mode disabled", () => { - const host = createTestHost({ quiet: false }) - const originalLog = console.log - - callPrivate(host, "setupQuietMode") - - expect(console.log).toBe(originalLog) - }) - it("should suppress console.log, warn, debug, info when enabled", () => { - const host = createTestHost({ quiet: true }) + const host = createTestHost() const originalLog = console.log callPrivate(host, "setupQuietMode") @@ -975,7 +841,7 @@ describe("ExtensionHost", () => { }) it("should preserve console.error", () => { - const host = createTestHost({ quiet: true }) + const host = createTestHost() const originalError = console.error callPrivate(host, "setupQuietMode") @@ -986,7 +852,7 @@ describe("ExtensionHost", () => { }) it("should store original console methods", () => { - const host = createTestHost({ quiet: true }) + const host = createTestHost() const originalLog = console.log callPrivate(host, "setupQuietMode") @@ -1000,7 +866,7 @@ describe("ExtensionHost", () => { describe("restoreConsole", () => { it("should restore original console methods", () => { - const host = createTestHost({ quiet: true }) + const host = createTestHost() const originalLog = console.log callPrivate(host, "setupQuietMode") @@ -1010,7 +876,7 @@ describe("ExtensionHost", () => { }) it("should handle case where console was not suppressed", () => { - const host = createTestHost({ quiet: false }) + const host = createTestHost() expect(() => { callPrivate(host, "restoreConsole") @@ -1140,7 +1006,7 @@ describe("ExtensionHost", () => { beforeEach(() => { host = createTestHost({ mode: "code", - apiProvider: "anthropic", + provider: "anthropic", apiKey: "test-key", model: "test-model", }) @@ -1210,7 +1076,7 @@ describe("ExtensionHost", () => { it("should use currentMode when set (from user mode switches)", () => { const host = createTestHost({ mode: "code", // Initial mode from CLI options - apiProvider: "anthropic", + provider: "anthropic", apiKey: "test-key", model: "test-model", }) @@ -1229,7 +1095,7 @@ describe("ExtensionHost", () => { it("should fall back to options.mode when currentMode is not set", () => { const host = createTestHost({ mode: "code", - apiProvider: "anthropic", + provider: "anthropic", apiKey: "test-key", model: "test-model", }) @@ -1247,7 +1113,7 @@ describe("ExtensionHost", () => { it("should use currentMode even when it differs from initial options.mode", () => { const host = createTestHost({ mode: "code", - apiProvider: "anthropic", + provider: "anthropic", apiKey: "test-key", model: "test-model", }) @@ -1265,7 +1131,7 @@ describe("ExtensionHost", () => { it("should not set mode if neither currentMode nor options.mode is set", () => { const host = createTestHost({ // No mode specified - mode defaults to "code" in createTestHost - apiProvider: "anthropic", + provider: "anthropic", apiKey: "test-key", model: "test-model", }) @@ -1290,7 +1156,7 @@ describe("ExtensionHost", () => { beforeEach(() => { host = createTestHost({ mode: "code", - apiProvider: "anthropic", + provider: "anthropic", apiKey: "test-key", model: "test-model", }) diff --git a/apps/cli/src/commands/auth/index.ts b/apps/cli/src/commands/auth/index.ts new file mode 100644 index 0000000000..52ae7673a7 --- /dev/null +++ b/apps/cli/src/commands/auth/index.ts @@ -0,0 +1,3 @@ +export * from "./login.js" +export * from "./logout.js" +export * from "./status.js" diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts new file mode 100644 index 0000000000..7ffdc43917 --- /dev/null +++ b/apps/cli/src/commands/auth/login.ts @@ -0,0 +1,179 @@ +import http from "http" +import { randomBytes } from "crypto" +import net from "net" +import { exec } from "child_process" + +import { AUTH_BASE_URL } from "../../constants.js" +import { saveToken } from "../../storage/credentials.js" + +export interface LoginOptions { + timeout?: number + verbose?: boolean +} + +export interface LoginResult { + success: boolean + error?: string + userId?: string + orgId?: string | null +} + +export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginOptions = {}): Promise { + const state = randomBytes(16).toString("hex") + const port = await getAvailablePort() + + if (verbose) { + console.log(`[Auth] Starting local callback server on port ${port}`) + } + + // Create promise that will be resolved when we receive the callback. + const tokenPromise = new Promise<{ token: string; state: string }>((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url!, `http://localhost:${port}`) + + if (url.pathname === "/callback") { + const receivedState = url.searchParams.get("state") + const token = url.searchParams.get("token") + const error = url.searchParams.get("error") + + if (error) { + const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=error-in-callback`) + errorUrl.searchParams.set("message", error) + res.writeHead(302, { Location: errorUrl.toString() }) + res.end() + // Wait for response to be fully sent before closing server and rejecting. + // The 'close' event fires when the underlying connection is terminated, + // ensuring the browser has received the redirect before we shut down. + res.on("close", () => { + server.close() + reject(new Error(error)) + }) + } else if (!token) { + const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=missing-token`) + errorUrl.searchParams.set("message", "Missing token in callback") + res.writeHead(302, { Location: errorUrl.toString() }) + res.end() + res.on("close", () => { + server.close() + reject(new Error("Missing token in callback")) + }) + } else if (receivedState !== state) { + const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=invalid-state-parameter`) + errorUrl.searchParams.set("message", "Invalid state parameter (possible CSRF attack)") + res.writeHead(302, { Location: errorUrl.toString() }) + res.end() + res.on("close", () => { + server.close() + reject(new Error("Invalid state parameter")) + }) + } else { + res.writeHead(302, { Location: `${AUTH_BASE_URL}/cli/sign-in?success=true` }) + res.end() + res.on("close", () => { + server.close() + resolve({ token, state: receivedState }) + }) + } + } else { + res.writeHead(404, { "Content-Type": "text/plain" }) + res.end("Not found") + } + }) + + server.listen(port, "127.0.0.1") + + const timeoutId = setTimeout(() => { + server.close() + reject(new Error("Authentication timed out")) + }, timeout) + + server.on("close", () => { + console.log("[Auth] Callback server closed") + clearTimeout(timeoutId) + }) + }) + + const authUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in`) + authUrl.searchParams.set("state", state) + authUrl.searchParams.set("callback", `http://localhost:${port}/callback`) + + console.log("Opening browser for authentication...") + console.log(`If the browser doesn't open, visit: ${authUrl.toString()}`) + + try { + await openBrowser(authUrl.toString()) + } catch (error) { + if (verbose) { + console.warn("[Auth] Failed to open browser automatically:", error) + } + + console.log("Please open the URL above in your browser manually.") + } + + try { + const { token } = await tokenPromise + await saveToken(token) + console.log("✓ Successfully authenticated!") + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`✗ Authentication failed: ${message}`) + return { success: false, error: message } + } +} + +async function getAvailablePort(startPort = 49152, endPort = 65535): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + let port = startPort + + const tryPort = () => { + server.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE" && port < endPort) { + port++ + tryPort() + } else { + reject(err) + } + }) + + server.once("listening", () => { + server.close(() => { + resolve(port) + }) + }) + + server.listen(port, "127.0.0.1") + } + + tryPort() + }) +} + +function openBrowser(url: string): Promise { + return new Promise((resolve, reject) => { + const platform = process.platform + let command: string + + switch (platform) { + case "darwin": + command = `open "${url}"` + break + case "win32": + command = `start "" "${url}"` + break + default: + // Linux and other Unix-like systems. + command = `xdg-open "${url}"` + break + } + + exec(command, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} diff --git a/apps/cli/src/commands/auth/logout.ts b/apps/cli/src/commands/auth/logout.ts new file mode 100644 index 0000000000..d90c731a8e --- /dev/null +++ b/apps/cli/src/commands/auth/logout.ts @@ -0,0 +1,29 @@ +import { clearToken } from "../../storage/credentials.js" +import { hasToken } from "../../storage/credentials.js" +import { getCredentialsPath } from "../../storage/credentials.js" + +export interface LogoutOptions { + verbose?: boolean +} + +export interface LogoutResult { + success: boolean + wasLoggedIn: boolean +} + +export async function logout({ verbose = false }: LogoutOptions = {}): Promise { + const wasLoggedIn = await hasToken() + + if (!wasLoggedIn) { + console.log("You are not currently logged in.") + return { success: true, wasLoggedIn: false } + } + + if (verbose) { + console.log(`[Auth] Removing credentials from ${getCredentialsPath()}`) + } + + await clearToken() + console.log("✓ Successfully logged out") + return { success: true, wasLoggedIn: true } +} diff --git a/apps/cli/src/commands/auth/status.ts b/apps/cli/src/commands/auth/status.ts new file mode 100644 index 0000000000..1abe425ccf --- /dev/null +++ b/apps/cli/src/commands/auth/status.ts @@ -0,0 +1,97 @@ +import { loadToken, loadCredentials, getCredentialsPath } from "../../storage/credentials.js" +import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "../../utils/auth-token.js" + +export interface StatusOptions { + verbose?: boolean +} + +export interface StatusResult { + authenticated: boolean + expired?: boolean + expiringSoon?: boolean + userId?: string + orgId?: string | null + expiresAt?: Date + createdAt?: Date +} + +export async function status(options: StatusOptions = {}): Promise { + const { verbose = false } = options + + const token = await loadToken() + + if (!token) { + console.log("✗ Not authenticated") + console.log("") + console.log("Run: roo auth login") + return { authenticated: false } + } + + const expiresAt = getTokenExpirationDate(token) + const expired = !isTokenValid(token) + const expiringSoon = isTokenExpired(token, 24 * 60 * 60) && !expired + + const credentials = await loadCredentials() + const createdAt = credentials?.createdAt ? new Date(credentials.createdAt) : undefined + + if (expired) { + console.log("✗ Authentication token expired") + console.log("") + console.log("Run: roo auth login") + + return { + authenticated: false, + expired: true, + expiresAt: expiresAt ?? undefined, + } + } + + if (expiringSoon) { + console.log("⚠ Expires soon; refresh with `roo auth login`") + } else { + console.log("✓ Authenticated") + } + + if (expiresAt) { + const remaining = getTimeRemaining(expiresAt) + console.log(` Expires: ${formatDate(expiresAt)} (${remaining})`) + } + + if (createdAt && verbose) { + console.log(` Created: ${formatDate(createdAt)}`) + } + + if (verbose) { + console.log(` Credentials: ${getCredentialsPath()}`) + } + + return { + authenticated: true, + expired: false, + expiringSoon, + expiresAt: expiresAt ?? undefined, + createdAt, + } +} + +function formatDate(date: Date): string { + return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }) +} + +function getTimeRemaining(date: Date): string { + const now = new Date() + const diff = date.getTime() - now.getTime() + + if (diff <= 0) { + return "expired" + } + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)) + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + + if (days > 0) { + return `${days} day${days === 1 ? "" : "s"}` + } + + return `${hours} hour${hours === 1 ? "" : "s"}` +} diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts new file mode 100644 index 0000000000..5d51ef99ab --- /dev/null +++ b/apps/cli/src/commands/index.ts @@ -0,0 +1 @@ +export * from "./auth/index.js" diff --git a/apps/cli/src/components/onboarding/OnboardingScreen.tsx b/apps/cli/src/components/onboarding/OnboardingScreen.tsx new file mode 100644 index 0000000000..ef64328e64 --- /dev/null +++ b/apps/cli/src/components/onboarding/OnboardingScreen.tsx @@ -0,0 +1,29 @@ +import { Box, Text } from "ink" +import { Select } from "@inkjs/ui" + +import { OnboardingProviderChoice } from "../../types.js" +import { ASCII_ROO } from "../../constants.js" + +export interface OnboardingScreenProps { + onSelect: (choice: OnboardingProviderChoice) => void +} + +export function OnboardingScreen({ onSelect }: OnboardingScreenProps) { + return ( + + + {ASCII_ROO} + + Welcome! How would you like to connect to an LLM provider? +