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 <noreply@anthropic.com>
This commit is contained in:
Chris Estreich 2026-02-04 16:17:29 -08:00 committed by GitHub
parent 1da2b1c457
commit 6e56619417
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 170 additions and 40 deletions

View file

@ -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`.

View file

@ -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"

View file

@ -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()

View file

@ -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

View file

@ -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")
vi.mocked(fs.existsSync).mockReturnValue(false)
// 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)
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)
})
})

View file

@ -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
}

View file

@ -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()

View file

@ -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)