Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"check-types": "tsc --noEmit",
"test": "vitest run",
"build": "tsup",
"build:extension": "pnpm --filter roo-cline bundle",
"build:all": "pnpm --filter roo-cline bundle && tsup",
"dev": "tsup --watch",
"start": "ROO_SDK_BASE_URL=http://localhost:3001 ROO_AUTH_BASE_URL=http://localhost:3000 node dist/index.js",
"start:production": "node dist/index.js",
Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/agent/__tests__/extension-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ function createTestHost({
model,
workspacePath: "/test/workspace",
extensionPath: "/test/extension",
ephemeral: false,
debug: false,
exitOnComplete: false,
...options,
})
}
Expand Down Expand Up @@ -94,6 +97,9 @@ describe("ExtensionHost", () => {
apiKey: "test-key",
provider: "openrouter",
model: "test-model",
ephemeral: false,
debug: false,
exitOnComplete: false,
}

const host = new ExtensionHost(options)
Expand Down
11 changes: 6 additions & 5 deletions apps/cli/src/agent/extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,17 @@ export interface ExtensionHostOptions {
workspacePath: string
extensionPath: string
nonInteractive?: boolean
debug?: boolean
/**
* When true, uses a temporary storage directory that is cleaned up on exit.
*/
ephemeral: boolean
debug: boolean
exitOnComplete: boolean
/**
* When true, completely disables all direct stdout/stderr output.
* Use this when running in TUI mode where Ink controls the terminal.
*/
disableOutput?: boolean
/**
* When true, uses a temporary storage directory that is cleaned up on exit.
*/
ephemeral?: boolean
/**
* When true, don't suppress node warnings and console output since we're
* running in an integration test and we want to see the output.
Expand Down
169 changes: 85 additions & 84 deletions apps/cli/src/commands/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { fileURLToPath } from "url"

import { createElement } from "react"

import { isProviderName } from "@roo-code/types"
import { setLogger } from "@roo-code/vscode-shim"

import {
Expand All @@ -18,8 +17,8 @@ import {
SDK_BASE_URL,
} from "@/types/index.js"

import { type User, createClient } from "@/lib/sdk/index.js"
import { loadToken, hasToken, loadSettings } from "@/lib/storage/index.js"
import { createClient } from "@/lib/sdk/index.js"
import { loadToken, loadSettings } from "@/lib/storage/index.js"
import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
import { runOnboarding } from "@/lib/utils/onboarding.js"
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
Expand All @@ -29,31 +28,35 @@ import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js"

const __dirname = path.dirname(fileURLToPath(import.meta.url))

export async function run(workspaceArg: string, options: FlagOptions) {
export async function run(workspaceArg: string, flagOptions: FlagOptions) {
setLogger({
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
})

const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
const isTuiEnabled = options.tui && isTuiSupported
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const workspacePath = path.resolve(workspaceArg)
// Options

if (!isSupportedProvider(options.provider)) {
console.error(
`[CLI] Error: Invalid provider: ${options.provider}; must be one of: ${supportedProviders.join(", ")}`,
)

process.exit(1)
const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
const isTuiEnabled = flagOptions.tui && isTuiSupported
const rooToken = await loadToken()

const extensionHostOptions: ExtensionHostOptions = {
mode: flagOptions.mode || DEFAULT_FLAGS.mode,
reasoningEffort: flagOptions.reasoningEffort === "unspecified" ? undefined : flagOptions.reasoningEffort,
user: null,
provider: flagOptions.provider ?? (rooToken ? "roo" : "openrouter"),
model: flagOptions.model || DEFAULT_FLAGS.model,
workspacePath: path.resolve(workspaceArg),
extensionPath: path.resolve(flagOptions.extension || getDefaultExtensionPath(__dirname)),
nonInteractive: flagOptions.yes,
ephemeral: flagOptions.ephemeral,
debug: flagOptions.debug,
exitOnComplete: flagOptions.exitOnComplete,
}

let apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
let provider = options.provider
let user: User | null = null
let useCloudProvider = false
// Roo Code Cloud Authentication

if (isTuiEnabled) {
let { onboardingProviderChoice } = await loadSettings()
Expand All @@ -64,92 +67,102 @@ export async function run(workspaceArg: string, options: FlagOptions) {
}

if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
useCloudProvider = true
const authenticated = await hasToken()

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.
}
extensionHostOptions.provider = "roo"
}
}

if (extensionHostOptions.provider === "roo") {
if (rooToken) {
try {
const client = createClient({ url: SDK_BASE_URL, authToken: rooToken })
const me = await client.auth.me.query()

if (me?.type !== "user") {
throw new Error("Invalid token")
}

extensionHostOptions.apiKey = rooToken
extensionHostOptions.user = me.user
} catch {
console.error("[CLI] Your Roo Code Router token is not valid.")
console.error("[CLI] Please run: roo auth login")
process.exit(1)
}
} else {
console.error("[CLI] Your Roo Code Router token is missing.")
console.error("[CLI] Please run: roo auth login")
process.exit(1)
}
}

if (!apiKey) {
if (useCloudProvider) {
// Validations
// TODO: Validate the API key for the chosen provider.
// TODO: Validate the model for the chosen provider.

if (!isSupportedProvider(extensionHostOptions.provider)) {
console.error(
`[CLI] Error: Invalid provider: ${extensionHostOptions.provider}; must be one of: ${supportedProviders.join(", ")}`,
)
process.exit(1)
}

extensionHostOptions.apiKey =
extensionHostOptions.apiKey || flagOptions.apiKey || getApiKeyFromEnv(extensionHostOptions.provider)

if (!extensionHostOptions.apiKey) {
if (extensionHostOptions.provider === "roo") {
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 ${provider}, set ${getEnvVarName(provider)}`)
console.error(
`[CLI] For ${extensionHostOptions.provider}, set ${getEnvVarName(extensionHostOptions.provider)}`,
)
}

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}`)
if (!fs.existsSync(extensionHostOptions.workspacePath)) {
console.error(`[CLI] Error: Workspace path does not exist: ${extensionHostOptions.workspacePath}`)
process.exit(1)
}

if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
if (extensionHostOptions.reasoningEffort && !REASONING_EFFORTS.includes(extensionHostOptions.reasoningEffort)) {
console.error(
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
`[CLI] Error: Invalid reasoning effort: ${extensionHostOptions.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
)
process.exit(1)
}

if (options.tui && !isTuiSupported) {
console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
}
if (!isTuiEnabled) {
if (!flagOptions.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 (!isTuiEnabled && !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 (flagOptions.tui) {
console.warn("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
}
}

// Run!

if (isTuiEnabled) {
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_FLAGS.model,
mode: options.mode || DEFAULT_FLAGS.mode,
nonInteractive: options.yes,
debug: options.debug,
exitOnComplete: options.exitOnComplete,
reasoningEffort: options.reasoningEffort,
ephemeral: options.ephemeral,
...extensionHostOptions,
initialPrompt: flagOptions.prompt,
version: VERSION,
// Create extension host factory for dependency injection.
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
}),
// Handle Ctrl+C in App component for double-press exit.
Expand All @@ -168,22 +181,10 @@ export async function run(workspaceArg: string, options: FlagOptions) {
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}`,
`[roo] Running ${extensionHostOptions.model || "default"} (${extensionHostOptions.reasoningEffort || "default"}) on ${extensionHostOptions.provider} in ${extensionHostOptions.mode || "default"} mode in ${extensionHostOptions.workspacePath} [debug = ${extensionHostOptions.debug}]`,
)

const host = new ExtensionHost({
mode: options.mode || DEFAULT_FLAGS.mode,
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
user,
provider,
apiKey,
model: options.model || DEFAULT_FLAGS.model,
workspacePath,
extensionPath: path.resolve(extensionPath),
nonInteractive: options.yes,
ephemeral: options.ephemeral,
debug: options.debug,
})
const host = new ExtensionHost(extensionHostOptions)

process.on("SIGINT", async () => {
console.log("\n[CLI] Received SIGINT, shutting down...")
Expand All @@ -199,10 +200,10 @@ export async function run(workspaceArg: string, options: FlagOptions) {

try {
await host.activate()
await host.runTask(options.prompt!)
await host.runTask(flagOptions.prompt!)
await host.dispose()

if (!options.waitOnComplete) {
if (!flagOptions.waitOnComplete) {
process.exit(0)
}
} catch (error) {
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ program
.option("-e, --extension <path>", "Path to the extension bundle directory")
.option("-d, --debug", "Enable debug output (includes detailed debug information)", 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 OPENROUTER_API_KEY env var)")
.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", "openrouter")
.option("-k, --api-key <key>", "API key for the LLM provider")
.option("-p, --provider <provider>", "API provider (roo, anthropic, openai, openrouter, etc.)")
.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode)
.option(
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type FlagOptions = {
debug: boolean
yes: boolean
apiKey?: string
provider: SupportedProvider
provider?: SupportedProvider
model?: string
mode?: string
reasoningEffort?: ReasoningEffortFlagOptions
Expand Down
5 changes: 2 additions & 3 deletions apps/cli/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,9 @@ import ScrollIndicator from "./components/ScrollIndicator.js"
const PICKER_HEIGHT = 10

export interface TUIAppProps extends ExtensionHostOptions {
initialPrompt: string
debug: boolean
exitOnComplete: boolean
initialPrompt?: string
version: string
// Create extension host factory for dependency injection.
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
}

Expand Down
7 changes: 5 additions & 2 deletions apps/cli/src/ui/hooks/useExtensionHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"

import { useCLIStore } from "../store.js"

// TODO: Unify with TUIAppProps?
export interface UseExtensionHostOptions extends ExtensionHostOptions {
initialPrompt?: string
exitOnComplete?: boolean
onExtensionMessage: (msg: ExtensionMessage) => void
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
}
Expand Down Expand Up @@ -42,6 +42,7 @@ export function useExtensionHost({
extensionPath,
nonInteractive,
ephemeral,
debug,
exitOnComplete,
onExtensionMessage,
createExtensionHost,
Expand Down Expand Up @@ -73,8 +74,10 @@ export function useExtensionHost({
workspacePath,
extensionPath,
nonInteractive,
disableOutput: true,
ephemeral,
debug,
exitOnComplete,
disableOutput: true,
})

hostRef.current = host
Expand Down
Loading