diff --git a/apps/cli/package.json b/apps/cli/package.json index 26588209961..a8fb1d1a47a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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" diff --git a/apps/cli/scripts/release.sh b/apps/cli/scripts/release.sh index 2e678dc7960..0eb225c5b2c 100755 --- a/apps/cli/scripts/release.sh +++ b/apps/cli/scripts/release.sh @@ -421,7 +421,7 @@ verify_local_install() { # Run the CLI with a simple prompt # Use timeout to prevent hanging if something goes wrong - if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --exit-on-complete --prompt "1+1=?" "$VERIFY_WORKSPACE" > "$VERIFY_DIR/test-output.log" 2>&1; then + if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --oneshot -w "$VERIFY_WORKSPACE" "1+1=?" > "$VERIFY_DIR/test-output.log" 2>&1; then info "End-to-end test passed" else EXIT_CODE=$? diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 88020ae3a74..a3ceec132fd 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -437,9 +437,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac this.sendToExtension({ type: "newTask", text: prompt }) return new Promise((resolve, reject) => { - let timeoutId: NodeJS.Timeout | null = null - const timeoutMs: number = 110_000 - const completeHandler = () => { cleanup() resolve() @@ -451,23 +448,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac } const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId) - timeoutId = null - } - this.client.off("taskCompleted", completeHandler) this.client.off("error", errorHandler) } - // Set timeout to prevent indefinite hanging. - timeoutId = setTimeout(() => { - cleanup() - reject( - new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`), - ) - }, timeoutMs) - this.client.once("taskCompleted", completeHandler) this.client.once("error", errorHandler) }) diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts index 14966f2d156..6cb452741ae 100644 --- a/apps/cli/src/commands/auth/login.ts +++ b/apps/cli/src/commands/auth/login.ts @@ -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" @@ -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 }) @@ -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) }) }) @@ -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}`) diff --git a/apps/cli/src/commands/cli/__tests__/run.test.ts b/apps/cli/src/commands/cli/__tests__/run.test.ts new file mode 100644 index 00000000000..7b7693a39cd --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/run.test.ts @@ -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 /`") + }) +}) diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 1479217679a..86ede4c8133 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -28,7 +28,7 @@ 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: () => {}, @@ -36,34 +36,60 @@ export async function run(workspaceArg: string, flagOptions: FlagOptions) { 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) { @@ -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 [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 --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") } } @@ -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), }), @@ -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)) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index f9c936333a1..e6644225622 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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 ", "The prompt/task to execute (optional in TUI mode)") + .argument("[prompt]", "Your prompt") + .option("--prompt-file ", "Read prompt from a file instead of command line argument") + .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") + .option("-p, --print", "Print response and exit (non-interactive mode)", false) .option("-e, --extension ", "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 ", "API key for the LLM provider") - .option("-p, --provider ", "API provider (roo, anthropic, openai, openrouter, etc.)") + .option("--provider ", "API provider (roo, anthropic, openai, openrouter, etc.)") .option("-m, --model ", "Model to use", DEFAULT_FLAGS.model) - .option("-M, --mode ", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode) + .option("--mode ", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode) .option( "-r, --reasoning-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) .action(run) const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud") diff --git a/apps/cli/src/lib/storage/__tests__/settings.test.ts b/apps/cli/src/lib/storage/__tests__/settings.test.ts new file mode 100644 index 00000000000..c133f733b92 --- /dev/null +++ b/apps/cli/src/lib/storage/__tests__/settings.test.ts @@ -0,0 +1,236 @@ +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 settings path 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-settings-test-${testRunId}`) + return { getTestConfigDir: () => testConfigDir } +}) + +vi.mock("../config-dir.js", () => ({ + getConfigDir: getTestConfigDir, +})) + +// Import after mocking +import { loadSettings, saveSettings, resetOnboarding, getSettingsPath } from "../settings.js" +import { OnboardingProviderChoice } from "@/types/index.js" + +// Re-derive the test config dir for use in tests (must match the hoisted one) +const actualTestConfigDir = getTestConfigDir() + +describe("Settings Storage", () => { + const expectedSettingsFile = path.join(actualTestConfigDir, "cli-settings.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("getSettingsPath", () => { + it("should return the correct settings file path", () => { + expect(getSettingsPath()).toBe(expectedSettingsFile) + }) + }) + + describe("loadSettings", () => { + it("should return empty object if no settings file exists", async () => { + const settings = await loadSettings() + expect(settings).toEqual({}) + }) + + it("should load saved settings", async () => { + const settingsData = { + onboardingProviderChoice: OnboardingProviderChoice.Roo, + mode: "architect", + provider: "anthropic" as const, + model: "claude-sonnet-4-20250514", + reasoningEffort: "high" as const, + } + + await fs.mkdir(actualTestConfigDir, { recursive: true }) + await fs.writeFile(expectedSettingsFile, JSON.stringify(settingsData), "utf-8") + + const loaded = await loadSettings() + expect(loaded).toEqual(settingsData) + }) + + it("should load settings with only some fields set", async () => { + const settingsData = { + mode: "code", + } + + await fs.mkdir(actualTestConfigDir, { recursive: true }) + await fs.writeFile(expectedSettingsFile, JSON.stringify(settingsData), "utf-8") + + const loaded = await loadSettings() + expect(loaded).toEqual(settingsData) + }) + }) + + describe("saveSettings", () => { + it("should save settings to disk", async () => { + await saveSettings({ mode: "debug" }) + + const savedData = await fs.readFile(expectedSettingsFile, "utf-8") + const settings = JSON.parse(savedData) + + expect(settings.mode).toBe("debug") + }) + + it("should merge settings with existing ones", async () => { + await saveSettings({ mode: "code" }) + await saveSettings({ provider: "openrouter" as const }) + + const savedData = await fs.readFile(expectedSettingsFile, "utf-8") + const settings = JSON.parse(savedData) + + expect(settings.mode).toBe("code") + expect(settings.provider).toBe("openrouter") + }) + + it("should save all default settings fields", async () => { + await saveSettings({ + mode: "architect", + provider: "anthropic" as const, + model: "claude-opus-4.5", + reasoningEffort: "medium" as const, + }) + + const savedData = await fs.readFile(expectedSettingsFile, "utf-8") + const settings = JSON.parse(savedData) + + expect(settings.mode).toBe("architect") + expect(settings.provider).toBe("anthropic") + expect(settings.model).toBe("claude-opus-4.5") + expect(settings.reasoningEffort).toBe("medium") + }) + + it("should create config directory if it doesn't exist", async () => { + await saveSettings({ mode: "ask" }) + + const dirStats = await fs.stat(actualTestConfigDir) + expect(dirStats.isDirectory()).toBe(true) + }) + + // Unix file permissions don't apply on Windows - skip this test + it.skipIf(process.platform === "win32")("should set restrictive file permissions", async () => { + await saveSettings({ mode: "code" }) + + const stats = await fs.stat(expectedSettingsFile) + // Check that only owner has read/write (mode 0o600) + const mode = stats.mode & 0o777 + expect(mode).toBe(0o600) + }) + }) + + describe("resetOnboarding", () => { + it("should reset onboarding provider choice", async () => { + await saveSettings({ onboardingProviderChoice: OnboardingProviderChoice.Roo }) + + await resetOnboarding() + + const settings = await loadSettings() + expect(settings.onboardingProviderChoice).toBeUndefined() + }) + + it("should preserve other settings when resetting onboarding", async () => { + await saveSettings({ + onboardingProviderChoice: OnboardingProviderChoice.Byok, + mode: "architect", + provider: "gemini" as const, + }) + + await resetOnboarding() + + const settings = await loadSettings() + expect(settings.onboardingProviderChoice).toBeUndefined() + expect(settings.mode).toBe("architect") + expect(settings.provider).toBe("gemini") + }) + }) + + describe("default settings priority", () => { + it("should support all configurable default settings", async () => { + // Test that all the settings that can be used as defaults are properly saved and loaded + const defaultSettings = { + mode: "debug", + provider: "openai-native" as const, + model: "gpt-4o", + reasoningEffort: "low" as const, + } + + await saveSettings(defaultSettings) + const loaded = await loadSettings() + + expect(loaded.mode).toBe("debug") + expect(loaded.provider).toBe("openai-native") + expect(loaded.model).toBe("gpt-4o") + expect(loaded.reasoningEffort).toBe("low") + }) + + it("should support dangerouslySkipPermissions setting", async () => { + await saveSettings({ dangerouslySkipPermissions: true }) + const loaded = await loadSettings() + + expect(loaded.dangerouslySkipPermissions).toBe(true) + }) + + it("should support all settings together including dangerouslySkipPermissions", async () => { + const allSettings = { + mode: "architect", + provider: "anthropic" as const, + model: "claude-sonnet-4-20250514", + reasoningEffort: "high" as const, + dangerouslySkipPermissions: true, + } + + await saveSettings(allSettings) + const loaded = await loadSettings() + + expect(loaded.mode).toBe("architect") + expect(loaded.provider).toBe("anthropic") + expect(loaded.model).toBe("claude-sonnet-4-20250514") + expect(loaded.reasoningEffort).toBe("high") + expect(loaded.dangerouslySkipPermissions).toBe(true) + }) + + it("should support oneshot setting", async () => { + await saveSettings({ oneshot: true }) + const loaded = await loadSettings() + + expect(loaded.oneshot).toBe(true) + }) + + it("should support all settings together including oneshot", async () => { + const allSettings = { + mode: "architect", + provider: "anthropic" as const, + model: "claude-sonnet-4-20250514", + reasoningEffort: "high" as const, + dangerouslySkipPermissions: true, + oneshot: true, + } + + await saveSettings(allSettings) + const loaded = await loadSettings() + + expect(loaded.mode).toBe("architect") + expect(loaded.provider).toBe("anthropic") + expect(loaded.model).toBe("claude-sonnet-4-20250514") + expect(loaded.reasoningEffort).toBe("high") + expect(loaded.dangerouslySkipPermissions).toBe(true) + expect(loaded.oneshot).toBe(true) + }) + }) +}) diff --git a/apps/cli/src/lib/utils/onboarding.ts b/apps/cli/src/lib/utils/onboarding.ts index 176bc6a3441..15da68f540c 100644 --- a/apps/cli/src/lib/utils/onboarding.ts +++ b/apps/cli/src/lib/utils/onboarding.ts @@ -17,9 +17,14 @@ export async function runOnboarding(): Promise { console.log("") if (choice === OnboardingProviderChoice.Roo) { - const { success: authenticated } = await login() + const result = await login() await saveSettings({ onboardingProviderChoice: choice }) - resolve({ choice: OnboardingProviderChoice.Roo, authenticated, skipped: false }) + + resolve({ + choice: OnboardingProviderChoice.Roo, + token: result.success ? result.token : undefined, + skipped: false, + }) } else { console.log("Using your own API key.") console.log("Set your API key via --api-key or environment variable.") diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 42c4e3a6fea..d5c71a330f6 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -18,19 +18,20 @@ export function isSupportedProvider(provider: string): provider is SupportedProv export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified" | "disabled" export type FlagOptions = { - prompt?: string + promptFile?: string + workspace?: string + print: boolean extension?: string debug: boolean yes: boolean + dangerouslySkipPermissions: boolean apiKey?: string provider?: SupportedProvider model?: string mode?: string reasoningEffort?: ReasoningEffortFlagOptions - exitOnComplete: boolean - waitOnComplete: boolean ephemeral: boolean - tui: boolean + oneshot: boolean } export enum OnboardingProviderChoice { @@ -40,10 +41,22 @@ export enum OnboardingProviderChoice { export interface OnboardingResult { choice: OnboardingProviderChoice - authenticated?: boolean + token?: string skipped: boolean } export interface CliSettings { onboardingProviderChoice?: OnboardingProviderChoice + /** Default mode to use (e.g., "code", "architect", "ask", "debug") */ + mode?: string + /** Default provider to use */ + provider?: SupportedProvider + /** Default model to use */ + model?: string + /** Default reasoning effort level */ + reasoningEffort?: ReasoningEffortFlagOptions + /** Auto-approve all prompts (use with caution) */ + dangerouslySkipPermissions?: boolean + /** Exit upon task completion */ + oneshot?: boolean } diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index fc2fc51addc..ee9bc41cee5 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -68,23 +68,24 @@ export interface TUIAppProps extends ExtensionHostOptions { /** * Inner App component that uses the terminal size context */ -function AppInner({ - initialPrompt, - workspacePath, - extensionPath, - user, - provider, - apiKey, - model, - mode, - nonInteractive = false, - debug, - exitOnComplete, - reasoningEffort, - ephemeral, - version, - createExtensionHost, -}: TUIAppProps) { +function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) { + const { + initialPrompt, + workspacePath, + extensionPath, + user, + provider, + apiKey, + model, + mode, + nonInteractive = false, + debug, + exitOnComplete, + reasoningEffort, + ephemeral, + version, + } = extensionHostOptions + const { exit } = useApp() const { @@ -454,12 +455,8 @@ function AppInner({ {/* Header - fixed size */}
{user && Welcome back, {user.name}} - cwd: {cwd.startsWith(homeDir) ? cwd.replace(homeDir, "~") : cwd} + cwd:{" "} + {workspacePath.startsWith(homeDir) ? workspacePath.replace(homeDir, "~") : workspacePath} {provider}: {model} [{reasoningEffort}] - mode: {mode} + + mode: {mode} + {nonInteractive && " (YOLO)"} + diff --git a/packages/evals/src/cli/runTaskInCli.ts b/packages/evals/src/cli/runTaskInCli.ts index 1f1ad79161a..79de3804528 100644 --- a/packages/evals/src/cli/runTaskInCli.ts +++ b/packages/evals/src/cli/runTaskInCli.ts @@ -1,4 +1,3 @@ -import * as fs from "fs" import * as path from "path" import * as os from "node:os" @@ -20,7 +19,7 @@ import { mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js" */ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => { const { language, exercise } = task - const prompt = fs.readFileSync(path.resolve(EVALS_REPO_PATH, `prompts/${language}.md`), "utf-8") + const promptSourcePath = path.resolve(EVALS_REPO_PATH, `prompts/${language}.md`) const workspacePath = path.resolve(EVALS_REPO_PATH, language, exercise) const ipcSocketPath = path.resolve(os.tmpdir(), `evals-cli-${run.id}-${task.id}.sock`) @@ -40,32 +39,31 @@ export const runTaskWithCli = async ({ run, task, publish, logger, jobToken }: R "--filter", "@roo-code/cli", "start", + "--prompt-file", + promptSourcePath, + "--workspace", + workspacePath, "--yes", - "--exit-on-complete", "--reasoning-effort", "disabled", - "--workspace", - workspacePath, + "--oneshot", ] if (run.settings?.mode) { - cliArgs.push("-M", run.settings.mode) + cliArgs.push("--mode", run.settings.mode) } if (run.settings?.apiProvider) { - cliArgs.push("-p", run.settings.apiProvider) + cliArgs.push("--provider", run.settings.apiProvider) } const modelId = run.settings?.apiModelId || run.settings?.openRouterModelId if (modelId) { - cliArgs.push("-m", modelId) + cliArgs.push("--model", modelId) } - cliArgs.push(prompt) - logger.info(`CLI command: pnpm ${cliArgs.join(" ")}`) - const subprocess = execa("pnpm", cliArgs, { env, cancelSignal, cwd: process.cwd() }) // Buffer for accumulating streaming output until we have complete lines.