Jelajahi Sumber

feat(cli): improve dev experience and roo provider API key support (#11203)

- Allow --api-key and ROO_API_KEY env var for the roo provider instead of
  requiring cloud auth token
- Switch dev/start scripts to use tsx for running directly from source
  without building first
- Fix path resolution (version.ts, extension.ts, extension-host.ts) to
  work from both source and bundled locations
- Disable debug log file (~/.roo/cli-debug.log) unless --debug is passed
- Update README with complete env var table and dev workflow docs

Co-authored-by: Claude Opus 4.5 <[email protected]>
Chris Estreich 1 Minggu lalu
induk
melakukan
6e56619417

+ 16 - 9
apps/cli/README.md

@@ -177,13 +177,14 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo
 
 The CLI will look for API keys in environment variables if not provided via `--api-key`:
 
-| Provider      | Environment Variable |
-| ------------- | -------------------- |
-| anthropic     | `ANTHROPIC_API_KEY`  |
-| openai        | `OPENAI_API_KEY`     |
-| openrouter    | `OPENROUTER_API_KEY` |
-| google/gemini | `GOOGLE_API_KEY`     |
-| ...           | ...                  |
+| Provider          | Environment Variable        |
+| ----------------- | --------------------------- |
+| roo               | `ROO_API_KEY`               |
+| anthropic         | `ANTHROPIC_API_KEY`         |
+| openai-native     | `OPENAI_API_KEY`            |
+| openrouter        | `OPENROUTER_API_KEY`        |
+| gemini            | `GOOGLE_API_KEY`            |
+| vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` |
 
 **Authentication Environment Variables:**
 
@@ -233,8 +234,8 @@ The CLI will look for API keys in environment variables if not provided via `--a
 ## Development
 
 ```bash
-# Watch mode for development
-pnpm dev
+# Run directly from source (no build required)
+pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello"
 
 # Run tests
 pnpm test
@@ -246,6 +247,12 @@ pnpm check-types
 pnpm lint
 ```
 
+By default the `start` script points `ROO_CODE_PROVIDER_URL` at `http://localhost:8080/proxy` for local development. To point at the production API instead, override the environment variable:
+
+```bash
+ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello"
+```
+
 ## Releasing
 
 Official releases are created via the GitHub Actions workflow at `.github/workflows/cli-release.yml`.

+ 2 - 2
apps/cli/package.json

@@ -16,8 +16,8 @@
 		"build": "tsup",
 		"build:extension": "pnpm --filter roo-cline bundle",
 		"build:all": "pnpm --filter roo-cline bundle && tsup",
-		"dev": "tsup --watch",
-		"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",
+		"dev": "tsx src/index.ts",
+		"start": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts",
 		"start:production": "node dist/index.js",
 		"build:local": "scripts/build.sh",
 		"clean": "rimraf dist .turbo"

+ 24 - 4
apps/cli/src/agent/extension-host.ts

@@ -24,7 +24,7 @@ import type {
 	WebviewMessage,
 } from "@roo-code/types"
 import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim"
-import { DebugLogger } from "@roo-code/core/cli"
+import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli"
 
 import type { SupportedProvider } from "@/types/index.js"
 import type { User } from "@/lib/sdk/index.js"
@@ -43,10 +43,25 @@ const cliLogger = new DebugLogger("CLI")
 
 // Get the CLI package root directory (for finding node_modules/@vscode/ripgrep)
 // When running from a release tarball, ROO_CLI_ROOT is set by the wrapper script.
-// In development, we fall back to calculating from __dirname.
-// After bundling with tsup, the code is in dist/index.js (flat), so we go up one level.
+// In development, we fall back to finding the CLI package root by walking up to package.json.
+// This works whether running from dist/ (bundled) or src/agent/ (tsx dev).
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
-const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..")
+
+function findCliPackageRoot(): string {
+	let dir = __dirname
+
+	while (dir !== path.dirname(dir)) {
+		if (fs.existsSync(path.join(dir, "package.json"))) {
+			return dir
+		}
+
+		dir = path.dirname(dir)
+	}
+
+	return path.resolve(__dirname, "..")
+}
+
+const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || findCliPackageRoot()
 
 export interface ExtensionHostOptions {
 	mode: string
@@ -154,6 +169,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
 
 		this.options = options
 
+		// Enable file-based debug logging only when --debug is passed.
+		if (options.debug) {
+			setDebugLogEnabled(true)
+		}
+
 		// Set up quiet mode early, before any extension code runs.
 		// This suppresses console output from the extension during load.
 		this.setupQuietMode()

+ 10 - 7
apps/cli/src/commands/cli/run.ts

@@ -112,15 +112,18 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
 				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)
+				// If an explicit API key was provided via flag or env var, fall through
+				// to the general API key resolution below instead of exiting.
+				if (!flagOptions.apiKey && !getApiKeyFromEnv(extensionHostOptions.provider)) {
+					console.error("[CLI] Your Roo Code Router token is not valid.")
+					console.error("[CLI] Please run: roo auth login")
+					console.error("[CLI] Or use --api-key or set ROO_API_KEY to provide your own API key.")
+					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 no rooToken, fall through to the general API key resolution below
+		// which will check flagOptions.apiKey and ROO_API_KEY env var.
 	}
 
 	// Validations

+ 66 - 7
apps/cli/src/lib/utils/__tests__/extension.test.ts

@@ -21,9 +21,26 @@ describe("getDefaultExtensionPath", () => {
 
 	it("should return monorepo path when extension.js exists there", () => {
 		const mockDirname = "/test/apps/cli/dist"
-		const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
+		const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist")
 
-		vi.mocked(fs.existsSync).mockReturnValue(true)
+		// Walk-up: dist/ has no package.json, apps/cli/ does
+		vi.mocked(fs.existsSync).mockImplementation((p) => {
+			const s = String(p)
+
+			if (s === path.join(mockDirname, "package.json")) {
+				return false
+			}
+
+			if (s === path.join("/test/apps/cli", "package.json")) {
+				return true
+			}
+
+			if (s === path.join(expectedMonorepoPath, "extension.js")) {
+				return true
+			}
+
+			return false
+		})
 
 		const result = getDefaultExtensionPath(mockDirname)
 
@@ -33,9 +50,18 @@ describe("getDefaultExtensionPath", () => {
 
 	it("should return package path when extension.js does not exist in monorepo path", () => {
 		const mockDirname = "/test/apps/cli/dist"
-		const expectedPackagePath = path.resolve(mockDirname, "../extension")
+		const expectedPackagePath = path.resolve("/test/apps/cli", "extension")
+
+		// Walk-up finds package.json at apps/cli/, but no extension.js in monorepo path
+		vi.mocked(fs.existsSync).mockImplementation((p) => {
+			const s = String(p)
 
-		vi.mocked(fs.existsSync).mockReturnValue(false)
+			if (s === path.join("/test/apps/cli", "package.json")) {
+				return true
+			}
+
+			return false
+		})
 
 		const result = getDefaultExtensionPath(mockDirname)
 
@@ -43,12 +69,45 @@ describe("getDefaultExtensionPath", () => {
 	})
 
 	it("should check monorepo path first", () => {
-		const mockDirname = "/some/path"
-		vi.mocked(fs.existsSync).mockReturnValue(false)
+		const mockDirname = "/test/apps/cli/dist"
+
+		vi.mocked(fs.existsSync).mockImplementation((p) => {
+			const s = String(p)
+
+			if (s === path.join("/test/apps/cli", "package.json")) {
+				return true
+			}
+
+			return false
+		})
 
 		getDefaultExtensionPath(mockDirname)
 
-		const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
+		const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist")
 		expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
 	})
+
+	it("should work when called from source directory (tsx dev)", () => {
+		const mockDirname = "/test/apps/cli/src/commands/cli"
+		const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist")
+
+		// Walk-up: no package.json in src subdirs, found at apps/cli/
+		vi.mocked(fs.existsSync).mockImplementation((p) => {
+			const s = String(p)
+
+			if (s === path.join("/test/apps/cli", "package.json")) {
+				return true
+			}
+
+			if (s === path.join(expectedMonorepoPath, "extension.js")) {
+				return true
+			}
+
+			return false
+		})
+
+		const result = getDefaultExtensionPath(mockDirname)
+
+		expect(result).toBe(expectedMonorepoPath)
+	})
 })

+ 16 - 7
apps/cli/src/lib/utils/extension.ts

@@ -17,17 +17,26 @@ export function getDefaultExtensionPath(dirname: string): string {
 		}
 	}
 
-	// __dirname is apps/cli/dist when bundled
-	// The extension is at src/dist (relative to monorepo root)
-	// So from apps/cli/dist, we need to go ../../../src/dist
-	const monorepoPath = path.resolve(dirname, "../../../src/dist")
+	// Find the CLI package root (apps/cli) by walking up to the nearest package.json.
+	// This works whether called from dist/ (bundled) or src/commands/cli/ (tsx dev).
+	let packageRoot = dirname
+
+	while (packageRoot !== path.dirname(packageRoot)) {
+		if (fs.existsSync(path.join(packageRoot, "package.json"))) {
+			break
+		}
+
+		packageRoot = path.dirname(packageRoot)
+	}
+
+	// The extension is at ../../src/dist relative to apps/cli (monorepo/src/dist)
+	const monorepoPath = path.resolve(packageRoot, "../../src/dist")
 
-	// Try monorepo path first (for development)
 	if (fs.existsSync(path.join(monorepoPath, "extension.js"))) {
 		return monorepoPath
 	}
 
-	// Fallback: when installed via curl script, extension is at ../extension
-	const packagePath = path.resolve(dirname, "../extension")
+	// Fallback: when installed via curl script, extension is at apps/cli/extension
+	const packagePath = path.resolve(packageRoot, "extension")
 	return packagePath
 }

+ 22 - 4
apps/cli/src/lib/utils/version.ts

@@ -1,6 +1,24 @@
-import { createRequire } from "module"
+import fs from "fs"
+import path from "path"
+import { fileURLToPath } from "url"
 
-const require = createRequire(import.meta.url)
-const packageJson = require("../package.json")
+// Walk up from the current file to find the nearest package.json.
+// This works whether running from source (tsx src/lib/utils/) or bundle (dist/).
+function findVersion(): string {
+	let dir = path.dirname(fileURLToPath(import.meta.url))
 
-export const VERSION = packageJson.version
+	while (dir !== path.dirname(dir)) {
+		const candidate = path.join(dir, "package.json")
+
+		if (fs.existsSync(candidate)) {
+			const packageJson = JSON.parse(fs.readFileSync(candidate, "utf-8"))
+			return packageJson.version
+		}
+
+		dir = path.dirname(dir)
+	}
+
+	return "0.0.0"
+}
+
+export const VERSION = findVersion()

+ 14 - 0
packages/core/src/debug-log/index.ts

@@ -21,11 +21,25 @@ import * as os from "os"
 
 const DEBUG_LOG_PATH = path.join(os.homedir(), ".roo", "cli-debug.log")
 
+let debugLogEnabled = false
+
+/**
+ * Enable or disable file-based debug logging.
+ * Logging is disabled by default and should only be enabled in dev/debug mode.
+ */
+export function setDebugLogEnabled(enabled: boolean): void {
+	debugLogEnabled = enabled
+}
+
 /**
  * Simple file-based debug log function.
  * Writes timestamped entries to ~/.roo/cli-debug.log
+ * Only writes when enabled via setDebugLogEnabled(true).
  */
 export function debugLog(message: string, data?: unknown): void {
+	if (!debugLogEnabled) {
+		return
+	}
 	try {
 		const logDir = path.dirname(DEBUG_LOG_PATH)