mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
parent
9a177cae28
commit
cf14c73095
38 changed files with 1337 additions and 764 deletions
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
3
apps/cli/src/commands/auth/index.ts
Normal file
3
apps/cli/src/commands/auth/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./login.js"
|
||||
export * from "./logout.js"
|
||||
export * from "./status.js"
|
||||
179
apps/cli/src/commands/auth/login.ts
Normal file
179
apps/cli/src/commands/auth/login.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
29
apps/cli/src/commands/auth/logout.ts
Normal file
29
apps/cli/src/commands/auth/logout.ts
Normal 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 }
|
||||
}
|
||||
97
apps/cli/src/commands/auth/status.ts
Normal file
97
apps/cli/src/commands/auth/status.ts
Normal 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"}`
|
||||
}
|
||||
1
apps/cli/src/commands/index.ts
Normal file
1
apps/cli/src/commands/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./auth/index.js"
|
||||
29
apps/cli/src/components/onboarding/OnboardingScreen.tsx
Normal file
29
apps/cli/src/components/onboarding/OnboardingScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
apps/cli/src/components/onboarding/index.ts
Normal file
1
apps/cli/src/components/onboarding/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./OnboardingScreen.js"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
30
apps/cli/src/sdk/client.ts
Normal file
30
apps/cli/src/sdk/client.ts
Normal 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
|
||||
}
|
||||
2
apps/cli/src/sdk/index.ts
Normal file
2
apps/cli/src/sdk/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./types.js"
|
||||
export * from "./client.js"
|
||||
31
apps/cli/src/sdk/types.ts
Normal file
31
apps/cli/src/sdk/types.ts
Normal 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
|
||||
}
|
||||
151
apps/cli/src/storage/__tests__/credentials.test.ts
Normal file
151
apps/cli/src/storage/__tests__/credentials.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
22
apps/cli/src/storage/config-dir.ts
Normal file
22
apps/cli/src/storage/config-dir.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
72
apps/cli/src/storage/credentials.ts
Normal file
72
apps/cli/src/storage/credentials.ts
Normal 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
|
||||
}
|
||||
3
apps/cli/src/storage/index.ts
Normal file
3
apps/cli/src/storage/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./config-dir.js"
|
||||
export * from "./settings.js"
|
||||
export * from "./credentials.js"
|
||||
39
apps/cli/src/storage/settings.ts
Normal file
39
apps/cli/src/storage/settings.ts
Normal 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
50
apps/cli/src/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
61
apps/cli/src/utils/auth-token.ts
Normal file
61
apps/cli/src/utils/auth-token.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
apps/cli/src/utils/onboarding.ts
Normal file
33
apps/cli/src/utils/onboarding.ts
Normal 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 }))
|
||||
})
|
||||
}
|
||||
|
|
@ -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
48
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue