Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Chris Estreich 2026-01-08 20:23:27 -08:00 committed by GitHub
parent 9a177cae28
commit cf14c73095
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1337 additions and 764 deletions

View file

@ -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 <prompt>` | The prompt/task to execute (optional in TUI mode) | None |
| `-e, --extension <path>` | 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
```

View file

@ -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": {

View file

@ -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

View file

@ -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<ExtensionHostOptions> = {}): 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<Record<string, unknown>>(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",
})

View file

@ -0,0 +1,3 @@
export * from "./login.js"
export * from "./logout.js"
export * from "./status.js"

View file

@ -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<LoginResult> {
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<number> {
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<void> {
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()
}
})
})
}

View file

@ -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<LogoutResult> {
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 }
}

View file

@ -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<StatusResult> {
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"}`
}

View file

@ -0,0 +1 @@
export * from "./auth/index.js"

View file

@ -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 (
<Box flexDirection="column" gap={1}>
<Text bold color="cyan">
{ASCII_ROO}
</Text>
<Text dimColor>Welcome! How would you like to connect to an LLM provider?</Text>
<Select
options={[
{ label: "Connect to Roo Code Cloud", value: OnboardingProviderChoice.Roo },
{ label: "Bring your own API key", value: OnboardingProviderChoice.Byok },
]}
onChange={(value: string) => {
onSelect(value as OnboardingProviderChoice)
}}
/>
</Box>
)
}

View file

@ -0,0 +1 @@
export * from "./OnboardingScreen.js"

View file

@ -1,5 +1,27 @@
import { reasoningEffortsExtended } from "@roo-code/types"
export const DEFAULT_FLAG_OPTIONS = {
mode: "code",
reasoningEffort: "medium" as const,
model: "anthropic/claude-opus-4.5",
}
export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
/**
* Default timeout in seconds for auto-approving followup questions.
* Used in both the TUI (App.tsx) and the extension host (extension-host.ts).
*/
export const FOLLOWUP_TIMEOUT_SECONDS = 60
export const ASCII_ROO = ` _,' ___
<__\\__/ \\
\\_ / _\\
\\,\\ / \\\\
// \\\\
,/' \`\\_,`
export const AUTH_BASE_URL = process.env.NODE_ENV === "production" ? "https://app.roocode.com" : "http://localhost:3000"
export const SDK_BASE_URL =
process.env.NODE_ENV === "production" ? "https://cloud-api.roocode.com" : "http://localhost:3001"

View file

@ -16,16 +16,13 @@ import fs from "fs"
import os from "os"
import readline from "readline"
import {
ProviderName,
ReasoningEffortExtended,
RooCodeSettings,
ExtensionMessage,
WebviewMessage,
} from "@roo-code/types"
import { ReasoningEffortExtended, RooCodeSettings, ExtensionMessage, WebviewMessage } from "@roo-code/types"
import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim"
import { debugLog, DebugLogger } from "@roo-code/core/debug-log"
import { SupportedProvider } from "./types.js"
import { FOLLOWUP_TIMEOUT_SECONDS } from "./constants.js"
import { User } from "./sdk/types.js"
// Pre-configured logger for CLI message activity debugging
const cliLogger = new DebugLogger("CLI")
@ -37,15 +34,15 @@ const CLI_PACKAGE_ROOT = path.resolve(__dirname, "..")
export interface ExtensionHostOptions {
mode: string
reasoningEffort?: ReasoningEffortExtended | "disabled"
apiProvider: ProviderName
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
user: User | null
provider: SupportedProvider
apiKey?: string
model: string
workspacePath: string
extensionPath: string
verbose?: boolean
quiet?: boolean
nonInteractive?: boolean
debug?: boolean
/**
* When true, completely disables all direct stdout/stderr output.
* Use this when running in TUI mode where Ink controls the terminal.
@ -143,11 +140,7 @@ export class ExtensionHost extends EventEmitter {
* but allows console.error through for critical errors.
*/
private setupQuietMode(): void {
if (!this.options.quiet) {
return
}
// Save original console methods
// Save original console methods.
this.originalConsole = {
log: console.log,
warn: console.warn,
@ -156,12 +149,11 @@ export class ExtensionHost extends EventEmitter {
info: console.info,
}
// Replace with no-op functions (except error)
// Replace with no-op functions (except error).
console.log = () => {}
console.warn = () => {}
console.debug = () => {}
console.info = () => {}
// Keep console.error for critical errors
}
/**
@ -352,7 +344,7 @@ export class ExtensionHost extends EventEmitter {
settings.mode = activeMode
}
if (this.options.reasoningEffort) {
if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") {
if (this.options.reasoningEffort === "disabled") {
settings.enableReasoningEffort = false
} else {
@ -395,8 +387,7 @@ export class ExtensionHost extends EventEmitter {
* Falls back to environment variables for API keys when not explicitly passed
*/
private buildApiConfiguration(): RooCodeSettings {
const provider = this.options.apiProvider || "anthropic"
// Try explicit API key first, then fall back to environment variable
const provider = this.options.provider
const apiKey = this.options.apiKey || this.getApiKeyFromEnv(provider)
const model = this.options.model
@ -409,142 +400,29 @@ export class ExtensionHost extends EventEmitter {
if (apiKey) config.apiKey = apiKey
if (model) config.apiModelId = model
break
case "openrouter":
if (apiKey) config.openRouterApiKey = apiKey
if (model) config.openRouterModelId = model
break
case "gemini":
if (apiKey) config.geminiApiKey = apiKey
if (model) config.apiModelId = model
break
case "openai-native":
if (apiKey) config.openAiNativeApiKey = apiKey
if (model) config.apiModelId = model
break
case "openai":
if (apiKey) config.openAiApiKey = apiKey
if (model) config.openAiModelId = model
break
case "mistral":
if (apiKey) config.mistralApiKey = apiKey
case "gemini":
if (apiKey) config.geminiApiKey = apiKey
if (model) config.apiModelId = model
break
case "deepseek":
if (apiKey) config.deepSeekApiKey = apiKey
if (model) config.apiModelId = model
case "openrouter":
if (apiKey) config.openRouterApiKey = apiKey
if (model) config.openRouterModelId = model
break
case "xai":
if (apiKey) config.xaiApiKey = apiKey
if (model) config.apiModelId = model
break
case "groq":
if (apiKey) config.groqApiKey = apiKey
if (model) config.apiModelId = model
break
case "fireworks":
if (apiKey) config.fireworksApiKey = apiKey
if (model) config.apiModelId = model
break
case "cerebras":
if (apiKey) config.cerebrasApiKey = apiKey
if (model) config.apiModelId = model
break
case "sambanova":
if (apiKey) config.sambaNovaApiKey = apiKey
if (model) config.apiModelId = model
break
case "ollama":
if (apiKey) config.ollamaApiKey = apiKey
if (model) config.ollamaModelId = model
break
case "lmstudio":
if (model) config.lmStudioModelId = model
break
case "litellm":
if (apiKey) config.litellmApiKey = apiKey
if (model) config.litellmModelId = model
break
case "huggingface":
if (apiKey) config.huggingFaceApiKey = apiKey
if (model) config.huggingFaceModelId = model
break
case "chutes":
if (apiKey) config.chutesApiKey = apiKey
if (model) config.apiModelId = model
break
case "featherless":
if (apiKey) config.featherlessApiKey = apiKey
if (model) config.apiModelId = model
break
case "unbound":
if (apiKey) config.unboundApiKey = apiKey
if (model) config.unboundModelId = model
break
case "requesty":
if (apiKey) config.requestyApiKey = apiKey
if (model) config.requestyModelId = model
break
case "deepinfra":
if (apiKey) config.deepInfraApiKey = apiKey
if (model) config.deepInfraModelId = model
break
case "vercel-ai-gateway":
if (apiKey) config.vercelAiGatewayApiKey = apiKey
if (model) config.vercelAiGatewayModelId = model
break
case "zai":
if (apiKey) config.zaiApiKey = apiKey
case "roo":
if (apiKey) config.rooApiKey = apiKey
if (model) config.apiModelId = model
break
case "baseten":
if (apiKey) config.basetenApiKey = apiKey
if (model) config.apiModelId = model
break
case "doubao":
if (apiKey) config.doubaoApiKey = apiKey
if (model) config.apiModelId = model
break
case "moonshot":
if (apiKey) config.moonshotApiKey = apiKey
if (model) config.apiModelId = model
break
case "minimax":
if (apiKey) config.minimaxApiKey = apiKey
if (model) config.apiModelId = model
break
case "io-intelligence":
if (apiKey) config.ioIntelligenceApiKey = apiKey
if (model) config.ioIntelligenceModelId = model
break
default:
console.log(provider, "unknown provider")
// Default to apiKey and apiModelId for unknown providers.
if (apiKey) config.apiKey = apiKey
if (model) config.apiModelId = model
@ -1232,12 +1110,7 @@ export class ExtensionHost extends EventEmitter {
*/
private promptForInputWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise<string> {
return new Promise((resolve) => {
// Temporarily restore console for interactive prompts
const wasQuiet = this.options.quiet
if (wasQuiet) {
this.restoreConsole()
}
this.restoreConsole()
// Put stdin in raw mode to detect individual keypresses.
const wasRaw = process.stdin.isRaw
@ -1275,10 +1148,7 @@ export class ExtensionHost extends EventEmitter {
}
process.stdin.pause()
if (wasQuiet) {
this.setupQuietMode()
}
this.setupQuietMode()
}
// Handle keypress data
@ -1579,48 +1449,29 @@ export class ExtensionHost extends EventEmitter {
*/
private promptForInput(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
// Temporarily restore console for interactive prompts
const wasQuiet = this.options.quiet
if (wasQuiet) {
this.restoreConsole()
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
this.restoreConsole()
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
rl.question(prompt, (answer) => {
rl.close()
// Restore quiet mode if it was enabled
if (wasQuiet) {
this.setupQuietMode()
}
this.setupQuietMode()
resolve(answer)
})
// Handle stdin close (e.g., piped input ended)
rl.on("close", () => {
if (wasQuiet) {
this.setupQuietMode()
}
this.setupQuietMode()
})
// Handle errors
rl.on("error", (err) => {
rl.close()
if (wasQuiet) {
this.setupQuietMode()
}
this.setupQuietMode()
reject(err)
})
})
}
/**
* Prompt user for yes/no input
* Prompt user for yes/no input.
*/
private async promptForYesNo(prompt: string): Promise<boolean> {
const answer = await this.promptForInput(prompt)

View file

@ -1,36 +1,26 @@
/**
* @roo-code/cli - Command Line Interface for Roo Code
*/
import { Command } from "commander"
import fs from "fs"
import { createRequire } from "module"
import path from "path"
import { fileURLToPath } from "url"
import { Command } from "commander"
import { createElement } from "react"
import {
type ProviderName,
type ReasoningEffortExtended,
isProviderName,
reasoningEffortsExtended,
} from "@roo-code/types"
import { isProviderName } from "@roo-code/types"
import { setLogger } from "@roo-code/vscode-shim"
import { ExtensionHost } from "./extension-host.js"
import { loadToken } from "./storage/credentials.js"
import { FlagOptions, isSupportedProvider, OnboardingProviderChoice, supportedProviders } from "./types.js"
import { ASCII_ROO, DEFAULT_FLAG_OPTIONS, REASONING_EFFORTS, SDK_BASE_URL } from "./constants.js"
import { ExtensionHost, ExtensionHostOptions } from "./extension-host.js"
import { login, logout, status } from "./commands/index.js"
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils/extensionHostUtils.js"
const DEFAULTS = {
mode: "code",
reasoningEffort: "medium" as const,
model: "anthropic/claude-sonnet-4.5",
}
const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
import { runOnboarding } from "./utils/onboarding.js"
import { type User, createClient } from "./sdk/index.js"
import { hasToken, loadSettings } from "./storage/index.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// Read version from package.json
const require = createRequire(import.meta.url)
const packageJson = require("../package.json")
@ -45,222 +35,241 @@ program
.argument("[workspace]", "Workspace path to operate in", process.cwd())
.option("-P, --prompt <prompt>", "The prompt/task to execute (optional in TUI mode)")
.option("-e, --extension <path>", "Path to the extension bundle directory")
.option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false)
.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
.option("-x, --exit-on-complete", "Exit the process when the task completes (useful for testing)", false)
.option("-y, --yes", "Auto-approve all prompts (non-interactive mode)", false)
.option("-k, --api-key <key>", "API key for the LLM provider (defaults to ANTHROPIC_API_KEY env var)")
.option("-k, --api-key <key>", "API key for the LLM provider (defaults to OPENROUTER_API_KEY env var)")
.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", "openrouter")
.option("-m, --model <model>", "Model to use", DEFAULTS.model)
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULTS.mode)
.option("-m, --model <model>", "Model to use", DEFAULT_FLAG_OPTIONS.model)
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAG_OPTIONS.mode)
.option(
"-r, --reasoning-effort <effort>",
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
DEFAULTS.reasoningEffort,
DEFAULT_FLAG_OPTIONS.reasoningEffort,
)
.option("-x, --exit-on-complete", "Exit the process when the task completes (applies to TUI mode only)", false)
.option(
"-w, --wait-on-complete",
"Keep the process running when the task completes (applies to plain text mode only)",
false,
)
.option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
.option("--no-tui", "Disable TUI, use plain text output")
.action(
async (
workspaceArg: string,
options: {
prompt?: string
extension?: string
verbose: boolean
debug: boolean
exitOnComplete: boolean
yes: boolean
apiKey?: string
provider: ProviderName
model?: string
mode?: string
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
ephemeral: boolean
tui: boolean
},
) => {
// Default is quiet mode - suppress VSCode shim logs unless verbose
// or debug is specified.
if (!options.verbose && !options.debug) {
setLogger({
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
})
.action(async (workspaceArg: string, options: FlagOptions) => {
setLogger({
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
})
const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const workspacePath = path.resolve(workspaceArg)
if (!isSupportedProvider(options.provider)) {
console.error(
`[CLI] Error: Invalid provider: ${options.provider}; must be one of: ${supportedProviders.join(", ")}`,
)
process.exit(1)
}
let apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
let provider = options.provider
let user: User | null = null
let useCloudProvider = false
if (isTuiSupported) {
let { onboardingProviderChoice } = await loadSettings()
if (!onboardingProviderChoice) {
const result = await runOnboarding()
onboardingProviderChoice = result.choice
}
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
const workspacePath = path.resolve(workspaceArg)
if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
useCloudProvider = true
const authenticated = await hasToken()
if (!apiKey) {
if (authenticated) {
const token = await loadToken()
if (token) {
try {
const client = createClient({ url: SDK_BASE_URL, authToken: token })
const me = await client.auth.me.query()
provider = "roo"
apiKey = token
user = me?.type === "user" ? me.user : null
} catch {
// Token may be expired or invalid - user will need to re-authenticate
}
}
}
}
}
if (!apiKey) {
if (useCloudProvider) {
console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.")
console.error("[CLI] Please run: roo auth login")
console.error("[CLI] Or use --api-key to provide your own API key.")
} else {
console.error(
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
)
console.error(`[CLI] For ${options.provider}, set ${getEnvVarName(options.provider)}`)
process.exit(1)
console.error(`[CLI] For ${provider}, set ${getEnvVarName(provider)}`)
}
process.exit(1)
}
if (!fs.existsSync(workspacePath)) {
console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
process.exit(1)
}
if (!fs.existsSync(workspacePath)) {
console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
process.exit(1)
}
if (!isProviderName(options.provider)) {
console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
process.exit(1)
}
if (!isProviderName(options.provider)) {
console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
process.exit(1)
}
if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
console.error(
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
console.error(
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
)
process.exit(1)
}
const useTui = options.tui && isTuiSupported
if (options.tui && !isTuiSupported) {
console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
}
if (!useTui && !options.prompt) {
console.error("[CLI] Error: prompt is required in plain text mode")
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
process.exit(1)
}
if (useTui) {
try {
const { render } = await import("ink")
const { App } = await import("./ui/App.js")
render(
createElement(App, {
initialPrompt: options.prompt || "",
workspacePath: workspacePath,
extensionPath: path.resolve(extensionPath),
user,
provider,
apiKey,
model: options.model || DEFAULT_FLAG_OPTIONS.model,
mode: options.mode || DEFAULT_FLAG_OPTIONS.mode,
nonInteractive: options.yes,
debug: options.debug,
exitOnComplete: options.exitOnComplete,
reasoningEffort: options.reasoningEffort,
ephemeral: options.ephemeral,
version: packageJson.version,
// Create extension host factory for dependency injection.
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
}),
// Handle Ctrl+C in App component for double-press exit.
{ exitOnCtrlC: false },
)
} catch (error) {
console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
if (error instanceof Error) {
console.error(error.stack)
}
process.exit(1)
}
} else {
console.log(ASCII_ROO)
console.log()
console.log(
`[roo] Running ${options.model || "default"} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
)
// TUI is enabled by default, disabled with --no-tui
// TUI requires raw mode support (proper TTY for stdin and stdout)
const canUseTui = process.stdin.isTTY && process.stdout.isTTY
const useTui = options.tui && canUseTui
const host = new ExtensionHost({
mode: options.mode || DEFAULT_FLAG_OPTIONS.mode,
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
user,
provider,
apiKey,
model: options.model || DEFAULT_FLAG_OPTIONS.model,
workspacePath,
extensionPath: path.resolve(extensionPath),
nonInteractive: options.yes,
ephemeral: options.ephemeral,
})
if (options.tui && !canUseTui) {
console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
}
process.on("SIGINT", async () => {
console.log("\n[CLI] Received SIGINT, shutting down...")
await host.dispose()
process.exit(130)
})
// In plain text mode, prompt is required
if (!useTui && !options.prompt) {
console.error("[CLI] Error: prompt is required in plain text mode")
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
process.on("SIGTERM", async () => {
console.log("\n[CLI] Received SIGTERM, shutting down...")
await host.dispose()
process.exit(143)
})
try {
await host.activate()
await host.runTask(options.prompt!)
await host.dispose()
if (!options.waitOnComplete) {
process.exit(0)
}
} catch (error) {
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
if (error instanceof Error) {
console.error(error.stack)
}
await host.dispose()
process.exit(1)
}
}
})
if (useTui) {
// TUI Mode - render Ink application
try {
const { render } = await import("ink")
const { App } = await import("./ui/App.js")
// Auth command group
const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
// Create extension host factory for dependency injection
const createExtensionHost = (opts: {
mode: string
reasoningEffort?: string
apiProvider: string
apiKey: string
model: string
workspacePath: string
extensionPath: string
verbose: boolean
quiet: boolean
nonInteractive: boolean
disableOutput: boolean
ephemeral?: boolean
}) => {
return new ExtensionHost({
mode: opts.mode,
reasoningEffort:
opts.reasoningEffort === "unspecified"
? undefined
: (opts.reasoningEffort as ReasoningEffortExtended | "disabled" | undefined),
apiProvider: opts.apiProvider as ProviderName,
apiKey: opts.apiKey,
model: opts.model,
workspacePath: opts.workspacePath,
extensionPath: opts.extensionPath,
verbose: opts.verbose,
quiet: opts.quiet,
nonInteractive: opts.nonInteractive,
disableOutput: opts.disableOutput,
ephemeral: opts.ephemeral,
})
}
authCommand
.command("login")
.description("Authenticate with Roo Code Cloud")
.option("-v, --verbose", "Enable verbose output", false)
.action(async (options: { verbose: boolean }) => {
const result = await login({ verbose: options.verbose })
process.exit(result.success ? 0 : 1)
})
render(
createElement(App, {
initialPrompt: options.prompt || "", // Empty string if no prompt - user will type in TUI
workspacePath: workspacePath,
extensionPath: path.resolve(extensionPath),
apiProvider: options.provider,
apiKey: apiKey,
model: options.model || DEFAULTS.model,
mode: options.mode || DEFAULTS.mode,
nonInteractive: options.yes,
verbose: options.verbose,
debug: options.debug,
exitOnComplete: options.exitOnComplete,
reasoningEffort: options.reasoningEffort,
ephemeral: options.ephemeral,
createExtensionHost: createExtensionHost,
version: packageJson.version,
}),
{
exitOnCtrlC: false, // Handle Ctrl+C in App component for double-press exit
},
)
} catch (error) {
console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
if (options.debug && error instanceof Error) {
console.error(error.stack)
}
process.exit(1)
}
} else {
// Plain text mode (existing behavior)
console.log(`[CLI] Mode: ${options.mode || "default"}`)
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
console.log(`[CLI] Provider: ${options.provider}`)
console.log(`[CLI] Model: ${options.model || "default"}`)
console.log(`[CLI] Workspace: ${workspacePath}`)
authCommand
.command("logout")
.description("Log out from Roo Code Cloud")
.option("-v, --verbose", "Enable verbose output", false)
.action(async (options: { verbose: boolean }) => {
const result = await logout({ verbose: options.verbose })
process.exit(result.success ? 0 : 1)
})
const host = new ExtensionHost({
mode: options.mode || DEFAULTS.mode,
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
apiProvider: options.provider,
apiKey,
model: options.model || DEFAULTS.model,
workspacePath,
extensionPath: path.resolve(extensionPath),
verbose: options.debug,
quiet: !options.verbose && !options.debug,
nonInteractive: options.yes,
ephemeral: options.ephemeral,
})
// Handle SIGINT (Ctrl+C)
process.on("SIGINT", async () => {
console.log("\n[CLI] Received SIGINT, shutting down...")
await host.dispose()
process.exit(130)
})
// Handle SIGTERM
process.on("SIGTERM", async () => {
console.log("\n[CLI] Received SIGTERM, shutting down...")
await host.dispose()
process.exit(143)
})
try {
await host.activate()
await host.runTask(options.prompt!) // prompt is guaranteed non-null in plain text mode
await host.dispose()
if (options.exitOnComplete) {
process.exit(0)
}
} catch (error) {
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
if (options.debug && error instanceof Error) {
console.error(error.stack)
}
await host.dispose()
process.exit(1)
}
}
},
)
authCommand
.command("status")
.description("Show authentication status")
.option("-v, --verbose", "Enable verbose output", false)
.action(async (options: { verbose: boolean }) => {
const result = await status({ verbose: options.verbose })
process.exit(result.authenticated ? 0 : 1)
})
program.parse()

View file

@ -0,0 +1,30 @@
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"
import superjson from "superjson"
import type { User, Org } from "./types.js"
export interface ClientConfig {
url: string
authToken: string
}
export interface RooClient {
auth: {
me: {
query: () => Promise<{ type: "user"; user: User } | { type: "org"; org: Org } | null>
}
}
}
export const createClient = ({ url, authToken }: ClientConfig): RooClient => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createTRPCProxyClient<any>({
links: [
httpBatchLink({
url: `${url}/trpc`,
transformer: superjson,
headers: () => (authToken ? { Authorization: `Bearer ${authToken}` } : {}),
}),
],
}) as unknown as RooClient
}

View file

@ -0,0 +1,2 @@
export * from "./types.js"
export * from "./client.js"

31
apps/cli/src/sdk/types.ts Normal file
View file

@ -0,0 +1,31 @@
export interface User {
id: string
name: string
email: string
imageUrl: string | null
entity: {
id: string
username: string | null
image_url: string
last_name: string
first_name: string
email_addresses: { email_address: string }[]
public_metadata: Record<string, unknown>
}
publicMetadata: Record<string, unknown>
stripeCustomerId: string | null
lastSyncAt: string
deletedAt: string | null
createdAt: string
updatedAt: string
}
export interface Org {
id: string
name: string
slug: string
imageUrl: string | null
createdAt: string
updatedAt: string
deletedAt: string | null
}

View file

@ -0,0 +1,151 @@
import fs from "fs/promises"
import path from "path"
// Use vi.hoisted to make the test directory available to the mock
// This must return the path synchronously since CREDENTIALS_FILE is computed at import time
const { getTestConfigDir } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require("os")
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require("path")
const testRunId = Date.now().toString()
const testConfigDir = path.join(os.tmpdir(), `roo-cli-test-${testRunId}`)
return { getTestConfigDir: () => testConfigDir }
})
vi.mock("../config-dir.js", () => ({
getConfigDir: getTestConfigDir,
}))
// Import after mocking
import { saveToken, loadToken, loadCredentials, clearToken, hasToken, getCredentialsPath } from "../credentials.js"
// Re-derive the test config dir for use in tests (must match the hoisted one)
const actualTestConfigDir = getTestConfigDir()
describe("Token Storage", () => {
const expectedCredentialsFile = path.join(actualTestConfigDir, "cli-credentials.json")
beforeEach(async () => {
// Clear test directory before each test
await fs.rm(actualTestConfigDir, { recursive: true, force: true })
})
afterAll(async () => {
// Clean up test directory
await fs.rm(actualTestConfigDir, { recursive: true, force: true })
})
describe("getCredentialsPath", () => {
it("should return the correct credentials file path", () => {
expect(getCredentialsPath()).toBe(expectedCredentialsFile)
})
})
describe("saveToken", () => {
it("should save token to disk", async () => {
const token = "test-token-123"
await saveToken(token)
const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
const credentials = JSON.parse(savedData)
expect(credentials.token).toBe(token)
expect(credentials.createdAt).toBeDefined()
})
it("should save token with user info", async () => {
const token = "test-token-456"
await saveToken(token, { userId: "user_123", orgId: "org_456" })
const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
const credentials = JSON.parse(savedData)
expect(credentials.token).toBe(token)
expect(credentials.userId).toBe("user_123")
expect(credentials.orgId).toBe("org_456")
})
it("should create config directory if it doesn't exist", async () => {
const token = "test-token-789"
await saveToken(token)
const dirStats = await fs.stat(actualTestConfigDir)
expect(dirStats.isDirectory()).toBe(true)
})
it("should set restrictive file permissions", async () => {
const token = "test-token-perms"
await saveToken(token)
const stats = await fs.stat(expectedCredentialsFile)
// Check that only owner has read/write (mode 0o600)
const mode = stats.mode & 0o777
expect(mode).toBe(0o600)
})
})
describe("loadToken", () => {
it("should load saved token", async () => {
const token = "test-token-abc"
await saveToken(token)
const loaded = await loadToken()
expect(loaded).toBe(token)
})
it("should return null if no token exists", async () => {
const loaded = await loadToken()
expect(loaded).toBeNull()
})
})
describe("loadCredentials", () => {
it("should load full credentials", async () => {
const token = "test-token-def"
await saveToken(token, { userId: "user_789" })
const credentials = await loadCredentials()
expect(credentials).not.toBeNull()
expect(credentials?.token).toBe(token)
expect(credentials?.userId).toBe("user_789")
expect(credentials?.createdAt).toBeDefined()
})
it("should return null if no credentials exist", async () => {
const credentials = await loadCredentials()
expect(credentials).toBeNull()
})
})
describe("clearToken", () => {
it("should remove saved token", async () => {
const token = "test-token-ghi"
await saveToken(token)
await clearToken()
const loaded = await loadToken()
expect(loaded).toBeNull()
})
it("should not throw if no token exists", async () => {
await expect(clearToken()).resolves.not.toThrow()
})
})
describe("hasToken", () => {
it("should return true if token exists", async () => {
await saveToken("test-token-jkl")
const exists = await hasToken()
expect(exists).toBe(true)
})
it("should return false if no token exists", async () => {
const exists = await hasToken()
expect(exists).toBe(false)
})
})
})

View file

@ -0,0 +1,22 @@
import fs from "fs/promises"
import os from "os"
import path from "path"
const CONFIG_DIR = path.join(os.homedir(), ".roo")
export function getConfigDir(): string {
return CONFIG_DIR
}
export async function ensureConfigDir(): Promise<void> {
try {
await fs.mkdir(CONFIG_DIR, { recursive: true })
} catch (err) {
// Directory may already exist, that's fine.
const error = err as NodeJS.ErrnoException
if (error.code !== "EEXIST") {
throw err
}
}
}

View file

@ -0,0 +1,72 @@
import fs from "fs/promises"
import path from "path"
import { getConfigDir } from "./index.js"
const CREDENTIALS_FILE = path.join(getConfigDir(), "cli-credentials.json")
export interface Credentials {
token: string
createdAt: string
userId?: string
orgId?: string
}
export async function saveToken(token: string, options?: { userId?: string; orgId?: string }): Promise<void> {
await fs.mkdir(getConfigDir(), { recursive: true })
const credentials: Credentials = {
token,
createdAt: new Date().toISOString(),
userId: options?.userId,
orgId: options?.orgId,
}
await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), {
mode: 0o600, // Read/write for owner only
})
}
export async function loadToken(): Promise<string | null> {
try {
const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
const credentials: Credentials = JSON.parse(data)
return credentials.token
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null
}
throw error
}
}
export async function loadCredentials(): Promise<Credentials | null> {
try {
const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
return JSON.parse(data) as Credentials
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null
}
throw error
}
}
export async function clearToken(): Promise<void> {
try {
await fs.unlink(CREDENTIALS_FILE)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
}
export async function hasToken(): Promise<boolean> {
const token = await loadToken()
return token !== null
}
export function getCredentialsPath(): string {
return CREDENTIALS_FILE
}

View file

@ -0,0 +1,3 @@
export * from "./config-dir.js"
export * from "./settings.js"
export * from "./credentials.js"

View file

@ -0,0 +1,39 @@
import fs from "fs/promises"
import path from "path"
import type { CliSettings } from "../types.js"
import { getConfigDir } from "./index.js"
export function getSettingsPath(): string {
return path.join(getConfigDir(), "cli-settings.json")
}
export async function loadSettings(): Promise<CliSettings> {
try {
const settingsPath = getSettingsPath()
const data = await fs.readFile(settingsPath, "utf-8")
return JSON.parse(data) as CliSettings
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw error
}
}
export async function saveSettings(settings: Partial<CliSettings>): Promise<void> {
const configDir = getConfigDir()
await fs.mkdir(configDir, { recursive: true })
const existing = await loadSettings()
const merged = { ...existing, ...settings }
await fs.writeFile(getSettingsPath(), JSON.stringify(merged, null, 2), {
mode: 0o600,
})
}
export async function resetOnboarding(): Promise<void> {
await saveSettings({ onboardingProviderChoice: undefined })
}

50
apps/cli/src/types.ts Normal file
View file

@ -0,0 +1,50 @@
import { ProviderName } from "@roo-code/types"
import { ReasoningEffortExtended } from "@roo-code/types"
export const supportedProviders = [
"anthropic",
"openai-native",
"gemini",
"openrouter",
"vercel-ai-gateway",
"roo",
] as const satisfies ProviderName[]
export type SupportedProvider = (typeof supportedProviders)[number]
export function isSupportedProvider(provider: string): provider is SupportedProvider {
return supportedProviders.includes(provider as SupportedProvider)
}
export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified" | "disabled"
export type FlagOptions = {
prompt?: string
extension?: string
debug: boolean
yes: boolean
apiKey?: string
provider: SupportedProvider
model?: string
mode?: string
reasoningEffort?: ReasoningEffortFlagOptions
exitOnComplete: boolean
waitOnComplete: boolean
ephemeral: boolean
tui: boolean
}
export enum OnboardingProviderChoice {
Roo = "roo",
Byok = "byok",
}
export interface OnboardingResult {
choice: OnboardingProviderChoice
authenticated?: boolean
skipped: boolean
}
export interface CliSettings {
onboardingProviderChoice?: OnboardingProviderChoice
}

View file

@ -6,7 +6,6 @@ import type { WebviewMessage } from "@roo-code/types"
import { getGlobalCommandsForAutocomplete } from "../utils/globalCommands.js"
import { arePathsEqual } from "../utils/pathUtils.js"
import { getContextWindow } from "../utils/getContextWindow.js"
import type { AppProps } from "./types.js"
import * as theme from "./theme.js"
import { useCLIStore } from "./store.js"
@ -55,6 +54,7 @@ import {
} from "./components/autocomplete/index.js"
import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js"
import ScrollIndicator from "./components/ScrollIndicator.js"
import { ExtensionHostOptions } from "../extension-host.js"
const PICKER_HEIGHT = 10
@ -67,24 +67,12 @@ interface ExtensionHostInterface {
dispose(): Promise<void>
}
interface ExtensionHostFactoryOptions {
mode: string
reasoningEffort?: string
apiProvider: string
apiKey: string
model: string
workspacePath: string
extensionPath: string
verbose: boolean
quiet: boolean
nonInteractive: boolean
disableOutput: boolean
ephemeral?: boolean
}
export interface TUIAppProps extends AppProps {
/** Extension host factory - allows dependency injection for testing. */
createExtensionHost: (options: ExtensionHostFactoryOptions) => ExtensionHostInterface
export interface TUIAppProps extends ExtensionHostOptions {
initialPrompt: string
debug: boolean
exitOnComplete: boolean
version: string
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
}
/**
@ -94,18 +82,18 @@ function AppInner({
initialPrompt,
workspacePath,
extensionPath,
apiProvider,
user,
provider,
apiKey,
model,
mode,
nonInteractive,
verbose,
nonInteractive = false,
debug,
exitOnComplete,
reasoningEffort,
ephemeral,
createExtensionHost,
version,
createExtensionHost,
}: TUIAppProps) {
const { exit } = useApp()
@ -173,35 +161,32 @@ function AppInner({
const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true })
const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom()
// RAF-style throttle refs for scroll updates (prevents multiple state updates per event loop tick)
// RAF-style throttle refs for scroll updates (prevents multiple state updates per event loop tick).
const rafIdRef = useRef<NodeJS.Immediate | null>(null)
const pendingScrollRef = useRef<{ scrollTop: number; maxScroll: number; isAtBottom: boolean } | null>(null)
// Toast notifications for ephemeral messages (e.g., mode changes)
// Toast notifications for ephemeral messages (e.g., mode changes).
const { currentToast, showInfo } = useToast()
// Initialize message handlers hook - provides refs and handler
const {
handleExtensionMessage,
seenMessageIds,
pendingCommandRef: _pendingCommandRef,
firstTextMessageSkipped,
} = useMessageHandlers({
verbose,
nonInteractive,
})
// Initialize extension host hook
const { sendToExtension, runTask, cleanup } = useExtensionHost({
initialPrompt,
mode,
reasoningEffort,
apiProvider,
user,
provider,
apiKey,
model,
workspacePath,
extensionPath,
verbose,
debug,
nonInteractive,
ephemeral,
@ -462,12 +447,11 @@ function AppInner({
<Text color={theme.dimText}>? for shortcuts</Text>
) : null
// Get render function for picker items based on active trigger
const getPickerRenderItem = () => {
if (pickerState.activeTrigger) {
return pickerState.activeTrigger.renderItem
}
// Default render
return (item: FileResult | SlashCommandResult, isSelected: boolean) => (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>{item.key}</Text>
@ -480,9 +464,11 @@ function AppInner({
{/* Header - fixed size */}
<Box flexShrink={0}>
<Header
cwd={workspacePath}
user={user}
provider={provider}
model={model}
mode={currentMode || mode}
cwd={workspacePath}
reasoningEffort={reasoningEffort}
version={version}
tokenUsage={tokenUsage}

View file

@ -1,3 +1,5 @@
import { RooCodeSettings } from "@roo-code/types"
import { useCLIStore } from "../store.js"
describe("useCLIStore", () => {
@ -166,7 +168,8 @@ describe("useCLIStore", () => {
})
it("should PRESERVE apiConfiguration", () => {
const config = { apiProvider: "openai", apiModelId: "gpt-4" }
const config: RooCodeSettings = { apiProvider: "openai", apiModelId: "gpt-4" }
useCLIStore
.getState()
.setApiConfiguration(config as ReturnType<typeof useCLIStore.getState>["apiConfiguration"])

View file

@ -3,12 +3,17 @@ import { Text, Box } from "ink"
import type { TokenUsage } from "@roo-code/types"
import { ASCII_ROO } from "../../constants.js"
import { User } from "../../sdk/types.js"
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
import * as theme from "../theme.js"
import MetricsDisplay from "./MetricsDisplay.js"
interface HeaderProps {
cwd: string
user: User | null
provider: string
model: string
mode: string
reasoningEffort?: string
@ -17,24 +22,22 @@ interface HeaderProps {
contextWindow?: number
}
const ASCII_ROO = ` _,' ___
<__\\__/ \\
\\_ / _\\
\\,\\ / \\\\
// \\\\
,/' \`\\_,`
function Header({ model, cwd, mode, reasoningEffort, version, tokenUsage, contextWindow }: HeaderProps) {
function Header({
cwd,
user,
provider,
model,
mode,
reasoningEffort,
version,
tokenUsage,
contextWindow,
}: HeaderProps) {
const { columns } = useTerminalSize()
const homeDir = process.env.HOME || process.env.USERPROFILE || ""
const displayCwd = cwd.startsWith(homeDir) ? cwd.replace(homeDir, "~") : cwd
const title = `Roo Code CLI v${version}`
const titlePart = `── ${title} `
const remainingDashes = Math.max(0, columns - titlePart.length)
// Only show metrics when we have token usage data
const showMetrics = tokenUsage && contextWindow && contextWindow > 0
const remainingDashes = Math.max(0, columns - `── ${title} `.length)
return (
<Box flexDirection="column" width={columns}>
@ -47,14 +50,18 @@ function Header({ model, cwd, mode, reasoningEffort, version, tokenUsage, contex
<Text color="magenta">{ASCII_ROO}</Text>
</Box>
<Box flexDirection="column" marginLeft={1} marginTop={1}>
<Text color={theme.dimText}>Workspace: {displayCwd}</Text>
<Text color={theme.dimText}>Mode: {mode}</Text>
<Text color={theme.dimText}>Model: {model}</Text>
<Text color={theme.dimText}>Reasoning: {reasoningEffort}</Text>
{user && <Text color={theme.dimText}>Welcome back, {user.name}</Text>}
<Text color={theme.dimText}>
cwd: {cwd.startsWith(homeDir) ? cwd.replace(homeDir, "~") : cwd}
</Text>
<Text color={theme.dimText}>
{provider}: {model} [{reasoningEffort}]
</Text>
<Text color={theme.dimText}>mode: {mode}</Text>
</Box>
</Box>
</Box>
{showMetrics && (
{tokenUsage && contextWindow && contextWindow > 0 && (
<Box alignSelf="flex-end" marginTop={-1}>
<MetricsDisplay tokenUsage={tokenUsage} contextWindow={contextWindow} />
</Box>

View file

@ -6,6 +6,8 @@ import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
import { toolInspectorLog, clearToolInspectorLog } from "../../utils/toolInspectorLogger.js"
import { useCLIStore } from "../store.js"
import { ExtensionHostOptions } from "../../extension-host.js"
interface ExtensionHostInterface {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
on(event: string, handler: (...args: any[]) => void): void
@ -15,40 +17,11 @@ interface ExtensionHostInterface {
dispose(): Promise<void>
}
export interface ExtensionHostOptions {
mode: string
reasoningEffort?: string
apiProvider: string
apiKey: string
model: string
workspacePath: string
extensionPath: string
verbose: boolean
debug: boolean
nonInteractive: boolean
ephemeral?: boolean
}
export interface UseExtensionHostOptions extends ExtensionHostOptions {
initialPrompt?: string
exitOnComplete?: boolean
onExtensionMessage: (msg: ExtensionMessage) => void
createExtensionHost: (options: ExtensionHostFactoryOptions) => ExtensionHostInterface
}
interface ExtensionHostFactoryOptions {
mode: string
reasoningEffort?: string
apiProvider: string
apiKey: string
model: string
workspacePath: string
extensionPath: string
verbose: boolean
quiet: boolean
nonInteractive: boolean
disableOutput: boolean
ephemeral?: boolean
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
}
export interface UseExtensionHostReturn {
@ -71,13 +44,12 @@ export function useExtensionHost({
initialPrompt,
mode,
reasoningEffort,
apiProvider,
user,
provider,
apiKey,
model,
workspacePath,
extensionPath,
verbose,
debug,
nonInteractive,
ephemeral,
exitOnComplete,
@ -90,7 +62,6 @@ export function useExtensionHost({
const hostRef = useRef<ExtensionHostInterface | null>(null)
const isReadyRef = useRef(false)
// Cleanup function
const cleanup = useCallback(async () => {
if (hostRef.current) {
await hostRef.current.dispose()
@ -99,10 +70,8 @@ export function useExtensionHost({
}
}, [])
// Initialize extension host
useEffect(() => {
const init = async () => {
// Clear tool inspector log for fresh session
clearToolInspectorLog()
toolInspectorLog("session:start", {
@ -114,14 +83,13 @@ export function useExtensionHost({
try {
const host = createExtensionHost({
mode,
reasoningEffort: reasoningEffort === "unspecified" ? undefined : reasoningEffort,
apiProvider,
user,
reasoningEffort,
provider,
apiKey,
model,
workspacePath,
extensionPath,
verbose: debug,
quiet: !verbose && !debug,
nonInteractive,
disableOutput: true,
ephemeral,
@ -135,6 +103,7 @@ export function useExtensionHost({
host.on("taskComplete", async () => {
setComplete(true)
setLoading(false)
if (exitOnComplete) {
await cleanup()
exit()
@ -149,7 +118,8 @@ export function useExtensionHost({
await host.activate()
// Request initial state from extension (triggers postStateToWebview which includes taskHistory)
// Request initial state from extension (triggers
// postStateToWebview which includes taskHistory).
host.sendToExtension({ type: "webviewDidLaunch" })
host.sendToExtension({ type: "requestCommands" })
host.sendToExtension({ type: "requestModes" })
@ -159,11 +129,7 @@ export function useExtensionHost({
if (initialPrompt) {
setHasStartedTask(true)
setLoading(true)
addMessage({
id: randomUUID(),
role: "user",
content: initialPrompt,
})
addMessage({ id: randomUUID(), role: "user", content: initialPrompt })
await host.runTask(initialPrompt)
}
} catch (err) {

View file

@ -14,7 +14,6 @@ import {
} from "../utils/toolDataUtils.js"
export interface UseMessageHandlersOptions {
verbose: boolean
nonInteractive: boolean
}
@ -35,7 +34,7 @@ export interface UseMessageHandlersReturn {
*
* Transforms ClineMessage format to TUIMessage format and updates the store.
*/
export function useMessageHandlers({ verbose, nonInteractive }: UseMessageHandlersOptions): UseMessageHandlersReturn {
export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions): UseMessageHandlersReturn {
const {
addMessage,
setPendingAsk,
@ -72,7 +71,7 @@ export function useMessageHandlers({ verbose, nonInteractive }: UseMessageHandle
return
}
if (say === "api_req_started" && !verbose) {
if (say === "api_req_started") {
return
}
@ -126,7 +125,7 @@ export function useMessageHandlers({ verbose, nonInteractive }: UseMessageHandle
toolData,
})
},
[addMessage, verbose],
[addMessage],
)
/**

View file

@ -108,23 +108,6 @@ export interface PendingAsk {
suggestions?: Array<{ answer: string; mode?: string | null }>
}
export interface AppProps {
initialPrompt: string
workspacePath: string
extensionPath: string
apiProvider: string
apiKey: string
model: string
mode: string
nonInteractive: boolean
verbose: boolean
debug: boolean
exitOnComplete: boolean
reasoningEffort?: string
ephemeral?: boolean
version: string
}
export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default"
export interface TaskHistoryItem {

View file

@ -1,42 +1,15 @@
import fs from "fs"
import path from "path"
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../extensionHostUtils.js"
import { getApiKeyFromEnv, getDefaultExtensionPath } from "../extensionHostUtils.js"
vi.mock("fs")
describe("getEnvVarName", () => {
it.each([
["anthropic", "ANTHROPIC_API_KEY"],
["openai", "OPENAI_API_KEY"],
["openrouter", "OPENROUTER_API_KEY"],
["google", "GOOGLE_API_KEY"],
["gemini", "GOOGLE_API_KEY"],
["bedrock", "AWS_ACCESS_KEY_ID"],
["ollama", "OLLAMA_API_KEY"],
["mistral", "MISTRAL_API_KEY"],
["deepseek", "DEEPSEEK_API_KEY"],
])("should return %s for %s provider", (provider, expectedEnvVar) => {
expect(getEnvVarName(provider)).toBe(expectedEnvVar)
})
it("should handle case-insensitive provider names", () => {
expect(getEnvVarName("ANTHROPIC")).toBe("ANTHROPIC_API_KEY")
expect(getEnvVarName("Anthropic")).toBe("ANTHROPIC_API_KEY")
expect(getEnvVarName("OpenRouter")).toBe("OPENROUTER_API_KEY")
})
it("should return uppercase provider name with _API_KEY suffix for unknown providers", () => {
expect(getEnvVarName("custom")).toBe("CUSTOM_API_KEY")
expect(getEnvVarName("myProvider")).toBe("MYPROVIDER_API_KEY")
})
})
describe("getApiKeyFromEnv", () => {
const originalEnv = process.env
beforeEach(() => {
// Reset process.env before each test
// Reset process.env before each test.
process.env = { ...originalEnv }
})
@ -56,23 +29,13 @@ describe("getApiKeyFromEnv", () => {
it("should return API key from environment variable for openai", () => {
process.env.OPENAI_API_KEY = "test-openai-key"
expect(getApiKeyFromEnv("openai")).toBe("test-openai-key")
expect(getApiKeyFromEnv("openai-native")).toBe("test-openai-key")
})
it("should return undefined when API key is not set", () => {
delete process.env.ANTHROPIC_API_KEY
expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
})
it("should handle custom provider names", () => {
process.env.CUSTOM_API_KEY = "test-custom-key"
expect(getApiKeyFromEnv("custom")).toBe("test-custom-key")
})
it("should handle case-insensitive provider lookup", () => {
process.env.ANTHROPIC_API_KEY = "test-key"
expect(getApiKeyFromEnv("ANTHROPIC")).toBe("test-key")
})
})
describe("getDefaultExtensionPath", () => {
@ -80,7 +43,7 @@ describe("getDefaultExtensionPath", () => {
beforeEach(() => {
vi.resetAllMocks()
// Reset process.env to avoid ROO_EXTENSION_PATH from installed CLI affecting tests
// Reset process.env to avoid ROO_EXTENSION_PATH from installed CLI affecting tests.
process.env = { ...originalEnv }
delete process.env.ROO_EXTENSION_PATH
})

View file

@ -5,9 +5,17 @@ import { getHistoryFilePath, loadHistory, saveHistory, addToHistory, MAX_HISTORY
vi.mock("fs/promises")
vi.mock("os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}))
vi.mock("os", async (importOriginal) => {
const actual = await importOriginal<typeof import("os")>()
return {
...actual,
default: {
...actual,
homedir: vi.fn(() => "/home/testuser"),
},
homedir: vi.fn(() => "/home/testuser"),
}
})
describe("historyStorage", () => {
beforeEach(() => {

View file

@ -0,0 +1,61 @@
export interface DecodedToken {
iss: string
sub: string
exp: number
iat: number
nbf: number
v: number
r?: {
u?: string
o?: string
t: string
}
}
export function decodeToken(token: string): DecodedToken | null {
try {
const parts = token.split(".")
if (parts.length !== 3) {
return null
}
const payload = parts[1]
if (!payload) {
return null
}
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
const decoded = Buffer.from(padded, "base64url").toString("utf-8")
return JSON.parse(decoded) as DecodedToken
} catch {
return null
}
}
export function isTokenExpired(token: string, bufferSeconds = 24 * 60 * 60): boolean {
const decoded = decodeToken(token)
if (!decoded?.exp) {
return true
}
const expiresAt = decoded.exp
const bufferTime = Math.floor(Date.now() / 1000) + bufferSeconds
return expiresAt < bufferTime
}
export function isTokenValid(token: string): boolean {
return !isTokenExpired(token, 0)
}
export function getTokenExpirationDate(token: string): Date | null {
const decoded = decodeToken(token)
if (!decoded?.exp) {
return null
}
return new Date(decoded.exp * 1000)
}

View file

@ -1,32 +1,24 @@
/**
* Utility functions for the Roo Code CLI
*/
import path from "path"
import fs from "fs"
/**
* Get the environment variable name for a provider's API key
*/
export function getEnvVarName(provider: string): string {
const envVarMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
google: "GOOGLE_API_KEY",
gemini: "GOOGLE_API_KEY",
bedrock: "AWS_ACCESS_KEY_ID",
ollama: "OLLAMA_API_KEY",
mistral: "MISTRAL_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
}
return envVarMap[provider.toLowerCase()] || `${provider.toUpperCase()}_API_KEY`
import type { SupportedProvider } from "../types.js"
const envVarMap: Record<SupportedProvider, string> = {
// Frontier Labs
anthropic: "ANTHROPIC_API_KEY",
"openai-native": "OPENAI_API_KEY",
gemini: "GOOGLE_API_KEY",
// Routers
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
roo: "ROO_API_KEY",
}
/**
* Get API key from environment variable based on provider
*/
export function getApiKeyFromEnv(provider: string): string | undefined {
export function getEnvVarName(provider: SupportedProvider): string {
return envVarMap[provider]
}
export function getApiKeyFromEnv(provider: SupportedProvider): string | undefined {
const envVar = getEnvVarName(provider)
return process.env[envVar]
}

View file

@ -1,6 +1,7 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
import { ensureConfigDir, getConfigDir } from "../storage/index.js"
/** Maximum number of history entries to keep */
export const MAX_HISTORY_ENTRIES = 500
@ -17,30 +18,7 @@ interface HistoryData {
* Get the path to the history file
*/
export function getHistoryFilePath(): string {
return path.join(os.homedir(), ".roo", "cli-history.json")
}
/**
* Get the path to the .roo directory
*/
function getRooDir(): string {
return path.join(os.homedir(), ".roo")
}
/**
* Ensure the .roo directory exists
*/
async function ensureRooDir(): Promise<void> {
const rooDir = getRooDir()
try {
await fs.mkdir(rooDir, { recursive: true })
} catch (err) {
// Directory may already exist, that's fine
const error = err as NodeJS.ErrnoException
if (error.code !== "EEXIST") {
throw err
}
}
return path.join(getConfigDir(), "cli-history.json")
}
/**
@ -95,7 +73,7 @@ export async function saveHistory(entries: string[]): Promise<void> {
}
try {
await ensureRooDir()
await ensureConfigDir()
await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf-8")
} catch (err) {
const error = err as NodeJS.ErrnoException

View file

@ -0,0 +1,33 @@
import { createElement } from "react"
import { type OnboardingResult, OnboardingProviderChoice } from "../types.js"
import { saveSettings } from "../storage/settings.js"
import { login } from "../commands/auth/login.js"
export async function runOnboarding(): Promise<OnboardingResult> {
const { render } = await import("ink")
const { OnboardingScreen } = await import("../components/onboarding/index.js")
return new Promise<OnboardingResult>((resolve) => {
const onSelect = async (choice: OnboardingProviderChoice) => {
await saveSettings({ onboardingProviderChoice: choice })
app.unmount()
console.log("")
if (choice === OnboardingProviderChoice.Roo) {
const { success: authenticated } = await login()
await saveSettings({ onboardingProviderChoice: choice })
resolve({ choice: OnboardingProviderChoice.Roo, authenticated, skipped: false })
} else {
console.log("Using your own API key.")
console.log("Set your API key via --api-key or environment variable.")
console.log("")
resolve({ choice: OnboardingProviderChoice.Byok, skipped: false })
}
}
const app = render(createElement(OnboardingScreen, { onSelect }))
})
}

View file

@ -408,7 +408,8 @@ const qwenCodeSchema = apiModelIdProviderModelSchema.extend({
})
const rooSchema = apiModelIdProviderModelSchema.extend({
// No additional fields needed - uses cloud authentication.
// Can use cloud authentication or provide an API key (cli).
rooApiKey: z.string().optional(),
})
const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({

48
pnpm-lock.yaml generated
View file

@ -94,6 +94,9 @@ importers:
'@roo-code/vscode-shim':
specifier: workspace:^
version: link:../../packages/vscode-shim
'@trpc/client':
specifier: ^11.8.1
version: 11.8.1(@trpc/server@11.8.1(typescript@5.8.3))(typescript@5.8.3)
'@vscode/ripgrep':
specifier: ^1.15.9
version: 1.17.0
@ -109,6 +112,9 @@ importers:
react:
specifier: ^19.1.0
version: 19.2.3
superjson:
specifier: ^2.2.6
version: 2.2.6
zustand:
specifier: ^5.0.0
version: 5.0.9(@types/react@18.3.23)(react@19.2.3)
@ -4032,6 +4038,17 @@ packages:
'@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
'@trpc/client@11.8.1':
resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==}
peerDependencies:
'@trpc/server': 11.8.1
typescript: '>=5.7.2'
'@trpc/server@11.8.1':
resolution: {integrity: sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA==}
peerDependencies:
typescript: '>=5.7.2'
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
@ -5212,6 +5229,10 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
copy-to-clipboard@3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
@ -7101,6 +7122,10 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
is-windows@1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
@ -9603,6 +9628,10 @@ packages:
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
@ -13913,6 +13942,15 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.8.3))(typescript@5.8.3)':
dependencies:
'@trpc/server': 11.8.1(typescript@5.8.3)
typescript: 5.8.3
'@trpc/server@11.8.1(typescript@5.8.3)':
dependencies:
typescript: 5.8.3
'@tybys/wasm-util@0.9.0':
dependencies:
tslib: 2.8.1
@ -15286,6 +15324,10 @@ snapshots:
cookie@0.7.2: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
copy-to-clipboard@3.3.3:
dependencies:
toggle-selection: 1.0.6
@ -17397,6 +17439,8 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
is-what@5.5.0: {}
is-windows@1.0.2: {}
is-wsl@3.1.0:
@ -20429,6 +20473,10 @@ snapshots:
pirates: 4.0.7
ts-interface-checker: 0.1.13
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0

View file

@ -42,7 +42,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
private currentReasoningDetails: any[] = []
constructor(options: ApiHandlerOptions) {
const sessionToken = getSessionToken()
const sessionToken = options.rooApiKey ?? getSessionToken()
let baseURL = process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy"
@ -64,6 +64,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
// Load dynamic models asynchronously - strip /v1 from baseURL for fetcher
this.fetcherBaseURL = baseURL.endsWith("/v1") ? baseURL.slice(0, -3) : baseURL
this.loadDynamicModels(this.fetcherBaseURL, sessionToken).catch((error) => {
console.error("[RooHandler] Failed to load dynamic models:", error)
})
@ -110,7 +111,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
}
try {
this.client.apiKey = getSessionToken()
this.client.apiKey = this.options.rooApiKey ?? getSessionToken()
return this.client.chat.completions.create(rooParams, requestOptions)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
@ -333,7 +334,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
}
override async completePrompt(prompt: string): Promise<string> {
// Update API key before making request to ensure we use the latest session token
this.client.apiKey = getSessionToken()
this.client.apiKey = this.options.rooApiKey ?? getSessionToken()
return super.completePrompt(prompt)
}
@ -400,7 +401,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
inputImage?: string,
apiMethod?: ImageGenerationApiMethod,
): Promise<ImageGenerationResult> {
const sessionToken = getSessionToken()
const sessionToken = this.options.rooApiKey ?? getSessionToken()
if (!sessionToken || sessionToken === "unauthenticated") {
return {