Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 5 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: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"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": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy node dist/index.js",
"start:production": "node dist/index.js",
"release": "scripts/release.sh",
"clean": "rimraf dist .turbo"
Expand Down
66 changes: 36 additions & 30 deletions apps/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ export interface LoginOptions {
verbose?: boolean
}

export interface LoginResult {
success: boolean
error?: string
userId?: string
orgId?: string | null
}
export type LoginResult =
| {
success: true
token: string
}
| {
success: false
error: string
}

const LOCALHOST = "127.0.0.1"

Expand All @@ -29,49 +32,57 @@ export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginO
console.log(`[Auth] Starting local callback server on port ${port}`)
}

const corsHeaders = {
"Access-Control-Allow-Origin": AUTH_BASE_URL,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}

// 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!, host)

if (url.pathname === "/callback") {
// Handle CORS preflight request.
if (req.method === "OPTIONS") {
res.writeHead(204, corsHeaders)
res.end()
return
}

if (url.pathname === "/callback" && req.method === "POST") {
const receivedState = url.searchParams.get("state")
const token = url.searchParams.get("token")
const error = url.searchParams.get("error")

const sendJsonResponse = (status: number, body: object) => {
res.writeHead(status, {
...corsHeaders,
"Content-Type": "application/json",
})
res.end(JSON.stringify(body))
}

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.
sendJsonResponse(400, { success: false, error })
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()
sendJsonResponse(400, { success: false, error: "Missing token in callback" })
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()
sendJsonResponse(400, { success: false, error: "Invalid state parameter" })
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()
sendJsonResponse(200, { success: true })
res.on("close", () => {
server.close()
resolve({ token, state: receivedState })
Expand All @@ -90,12 +101,7 @@ export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginO
reject(new Error("Authentication timed out"))
}, timeout)

server.on("listening", () => {
console.log(`[Auth] Callback server listening on port ${port}`)
})

server.on("close", () => {
console.log("[Auth] Callback server closed")
clearTimeout(timeoutId)
})
})
Expand All @@ -121,7 +127,7 @@ export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginO
const { token } = await tokenPromise
await saveToken(token)
console.log("✓ Successfully authenticated!")
return { success: true }
return { success: true, token }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`✗ Authentication failed: ${message}`)
Expand Down
93 changes: 93 additions & 0 deletions apps/cli/src/commands/cli/__tests__/run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import fs from "fs"
import path from "path"
import os from "os"

describe("run command --prompt-file option", () => {
let tempDir: string
let promptFilePath: string

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-test-"))
promptFilePath = path.join(tempDir, "prompt.md")
})

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})

it("should read prompt from file when --prompt-file is provided", () => {
const promptContent = `This is a test prompt with special characters:
- Quotes: "hello" and 'world'
- Backticks: \`code\`
- Newlines and tabs
- Unicode: 你好 🎉`

fs.writeFileSync(promptFilePath, promptContent)

// Verify the file was written correctly
const readContent = fs.readFileSync(promptFilePath, "utf-8")
expect(readContent).toBe(promptContent)
})

it("should handle multi-line prompts correctly", () => {
const multiLinePrompt = `Line 1
Line 2
Line 3

Empty line above
\tTabbed line
Indented line`

fs.writeFileSync(promptFilePath, multiLinePrompt)
const readContent = fs.readFileSync(promptFilePath, "utf-8")

expect(readContent).toBe(multiLinePrompt)
expect(readContent.split("\n")).toHaveLength(7)
})

it("should handle very long prompts that would exceed ARG_MAX", () => {
// ARG_MAX is typically 128KB-2MB, so let's test with a 500KB prompt
const longPrompt = "x".repeat(500 * 1024)

fs.writeFileSync(promptFilePath, longPrompt)
const readContent = fs.readFileSync(promptFilePath, "utf-8")

expect(readContent.length).toBe(500 * 1024)
expect(readContent).toBe(longPrompt)
})

it("should preserve shell-sensitive characters", () => {
const shellSensitivePrompt = `
$HOME
$(echo dangerous)
\`rm -rf /\`
"quoted string"
'single quoted'
$((1+1))
&&
||
;
> /dev/null
< input.txt
| grep something
*
?
[abc]
{a,b}
~
!
#comment
%s
\n\t\r
`

fs.writeFileSync(promptFilePath, shellSensitivePrompt)
const readContent = fs.readFileSync(promptFilePath, "utf-8")

// All shell-sensitive characters should be preserved exactly
expect(readContent).toBe(shellSensitivePrompt)
expect(readContent).toContain("$HOME")
expect(readContent).toContain("$(echo dangerous)")
expect(readContent).toContain("`rm -rf /`")
})
})
75 changes: 49 additions & 26 deletions apps/cli/src/commands/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,42 +28,68 @@ import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js"

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

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

let prompt = promptArg

if (flagOptions.promptFile) {
if (!fs.existsSync(flagOptions.promptFile)) {
console.error(`[CLI] Error: Prompt file does not exist: ${flagOptions.promptFile}`)
process.exit(1)
}

prompt = fs.readFileSync(flagOptions.promptFile, "utf-8")
}

// Options

let rooToken = await loadToken()
const settings = await loadSettings()

const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
const isTuiEnabled = flagOptions.tui && isTuiSupported
const rooToken = await loadToken()
const isTuiEnabled = !flagOptions.print && isTuiSupported
const isOnboardingEnabled = isTuiEnabled && !rooToken && !flagOptions.provider && !settings.provider

// Determine effective values: CLI flags > settings file > DEFAULT_FLAGS.
const effectiveMode = flagOptions.mode || settings.mode || DEFAULT_FLAGS.mode
const effectiveModel = flagOptions.model || settings.model || DEFAULT_FLAGS.model
const effectiveReasoningEffort =
flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort
const effectiveProvider = flagOptions.provider ?? settings.provider ?? (rooToken ? "roo" : "openrouter")
const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd()
const effectiveDangerouslySkipPermissions =
flagOptions.yes || flagOptions.dangerouslySkipPermissions || settings.dangerouslySkipPermissions || false
const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false

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

// Roo Code Cloud Authentication

if (isTuiEnabled) {
let { onboardingProviderChoice } = await loadSettings()
if (isOnboardingEnabled) {
let { onboardingProviderChoice } = settings

if (!onboardingProviderChoice) {
const result = await runOnboarding()
onboardingProviderChoice = result.choice
const { choice, token } = await runOnboarding()
onboardingProviderChoice = choice
rooToken = token ?? null
}

if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
Expand Down Expand Up @@ -139,15 +165,15 @@ export async function run(workspaceArg: string, flagOptions: FlagOptions) {
}

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")
if (!prompt) {
console.error("[CLI] Error: prompt is required in print mode")
console.error("[CLI] Usage: roo <prompt> --print [options]")
console.error("[CLI] Run without -p for interactive mode")
process.exit(1)
}

if (flagOptions.tui) {
console.warn("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
if (!flagOptions.print) {
console.warn("[CLI] TUI disabled (no TTY support), falling back to print mode")
}
}

Expand All @@ -161,7 +187,7 @@ export async function run(workspaceArg: string, flagOptions: FlagOptions) {
render(
createElement(App, {
...extensionHostOptions,
initialPrompt: flagOptions.prompt,
initialPrompt: prompt,
version: VERSION,
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
}),
Expand Down Expand Up @@ -200,12 +226,9 @@ export async function run(workspaceArg: string, flagOptions: FlagOptions) {

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

if (!flagOptions.waitOnComplete) {
process.exit(0)
}
process.exit(0)
} catch (error) {
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))

Expand Down
25 changes: 12 additions & 13 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,30 @@ import { run, login, logout, status } from "@/commands/index.js"

const program = new Command()

program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version(VERSION)
program
.name("roo")
.description("Roo Code CLI - starts an interactive session by default, use -p/--print for non-interactive output")
.version(VERSION)

program
.argument("[workspace]", "Workspace path to operate in", process.cwd())
.option("-P, --prompt <prompt>", "The prompt/task to execute (optional in TUI mode)")
.argument("[prompt]", "Your prompt")
.option("--prompt-file <path>", "Read prompt from a file instead of command line argument")
.option("-w, --workspace <path>", "Workspace directory path (defaults to current working directory)")
.option("-p, --print", "Print response and exit (non-interactive mode)", false)
.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("-y, --yes, --dangerously-skip-permissions", "Auto-approve all prompts (use with caution)", false)
.option("-k, --api-key <key>", "API key for the LLM provider")
.option("-p, --provider <provider>", "API provider (roo, anthropic, openai, openrouter, etc.)")
.option("--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("--mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode)
.option(
"-r, --reasoning-effort <effort>",
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
DEFAULT_FLAGS.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")
.option("--oneshot", "Exit upon task completion", false)
Comment thread
cursor[bot] marked this conversation as resolved.
.action(run)

const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
Expand Down
Loading
Loading