Merge branch 'RooCodeInc:main' into add-dart-codebase-index

This commit is contained in:
mahaat 2026-01-07 15:19:14 +07:00 committed by GitHub
commit 52c2574bec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
419 changed files with 30183 additions and 3536 deletions

View file

@ -76,14 +76,18 @@ src/node_modules
!pnpm-workspace.yaml
!scripts/bootstrap.mjs
!apps/web-evals/
!apps/cli/
!src/
!webview-ui/
!packages/evals/.docker/entrypoints/runner.sh
!packages/build/
!packages/config-eslint/
!packages/config-typescript/
!packages/core/
!packages/evals/
!packages/ipc/
!packages/telemetry/
!packages/types/
!packages/vscode-shim/
!packages/cloud/
!locales/

View file

@ -84,7 +84,6 @@ body:
- Google Gemini
- Google Vertex AI
- Groq
- Human Relay Provider
- LiteLLM
- LM Studio
- Mistral AI

View file

@ -1,6 +1,7 @@
---
description: "Create a new release of the Roo Code extension"
argument-hint: patch | minor | major
mode: code
---
1. Identify the SHA corresponding to the most recent release using GitHub CLI: `gh release view --json tagName,targetCommitish,publishedAt`

View file

@ -16,7 +16,6 @@
| Auto-approve | 自动批准 | 始终批准 | 权限相关术语 |
| Checkpoint | 存档点 | 检查点/快照 | 技术概念统一 |
| MCP Server | MCP 服务 | MCP 服务器 | 技术组件 |
| Human Relay | 人工辅助模式 | 人工中继 | 功能描述清晰 |
| Network Timeout | 请求超时 | 网络超时 | 更准确描述 |
| Terminal | 终端 | 命令行 | 技术术语统一 |
| diff | 差异更新 | 差分/补丁 | 代码变更 |

View file

@ -1,5 +1,81 @@
# Roo Code Changelog
## [3.38.3] - 2026-01-03
- Feat: Add option in Context settings to recursively load `.roo/rules` and `AGENTS.md` from subdirectories (PR #10446 by @mrubens)
- Fix: Stop frequent Claude Code sign-ins by hardening OAuth refresh token handling (PR #10410 by @hannesrudolph)
- Fix: Add `maxConcurrentFileReads` limit to native `read_file` tool schema (PR #10449 by @app/roomote)
- Fix: Add type check for `lastMessage.text` in TTS useEffect to prevent runtime errors (PR #10431 by @app/roomote)
## [3.38.2] - 2025-12-31
![3.38.2 Release - Skill Alignment](/releases/3.38.2-release.png)
- Align skills system with Agent Skills specification (PR #10409 by @hannesrudolph)
- Prevent write_to_file from creating files at truncated paths (PR #10415 by @mrubens and @daniel-lxs)
- Update Cerebras maxTokens to 16384 (PR #10387 by @sebastiand-cerebras)
- Fix rate limit wait display (PR #10389 by @hannesrudolph)
- Remove human-relay provider (PR #10388 by @hannesrudolph)
- Replace Todo Lists video with Context Management video in documentation (PR #10375 by @SannidhyaSah)
## [3.38.1] - 2025-12-29
![3.38.1 Release - Bug Fixes and Stability](/releases/3.38.1-release.png)
- Fix: Flush pending tool results before condensing context (PR #10379 by @daniel-lxs)
- Fix: Revert mergeToolResultText for OpenAI-compatible providers (PR #10381 by @hannesrudolph)
- Fix: Enforce maxConcurrentFileReads limit in read_file tool (PR #10363 by @roomote)
- Fix: Improve feedback message when read_file is used on a directory (PR #10371 by @roomote)
- Fix: Handle custom tool use similarly to MCP tools for IPC schema purposes (PR #10364 by @jr)
- Fix: Correct GitHub repository URL in marketing page (#10376 by @jishnuteegala, PR #10377 by @roomote)
- Docs: Clarify path to Security Settings in privacy policy (PR #10367 by @roomote)
## [3.38.0] - 2025-12-27
![3.38.0 Release - Skills](/releases/3.38.0-release.png)
- Add support for [Agent Skills](https://agentskills.io/), enabling reusable packages of prompts, tools, and resources to extend Roo's capabilities (PR #10335 by @mrubens)
- Add optional mode field to slash command front matter, allowing commands to automatically switch to a specific mode when triggered (PR #10344 by @app/roomote)
- Add support for npm packages and .env files to custom tools, allowing custom tools to import dependencies and access environment variables (PR #10336 by @cte)
- Remove simpleReadFileTool feature, streamlining the file reading experience (PR #10254 by @app/roomote)
- Remove OpenRouter Transforms feature (PR #10341 by @app/roomote)
- Fix mergeToolResultText handling in Roo provider (PR #10359 by @mrubens)
## [3.37.1] - 2025-12-23
![3.37.1 Release - Tool Fixes and Provider Improvements](/releases/3.37.1-release.png)
- Fix: Send native tool definitions by default for OpenAI to ensure proper tool usage (PR #10314 by @hannesrudolph)
- Fix: Preserve reasoning_details shape to prevent malformed responses when processing model output (PR #10313 by @hannesrudolph)
- Fix: Drain queued messages while waiting for ask to prevent message loss (PR #10315 by @hannesrudolph)
- Feat: Add grace retry for empty assistant messages to improve reliability (PR #10297 by @hannesrudolph)
- Feat: Enable mergeToolResultText for all OpenAI-compatible providers for better tool result handling (PR #10299 by @hannesrudolph)
- Feat: Enable mergeToolResultText for Roo Code Cloud provider (PR #10301 by @hannesrudolph)
- Feat: Strengthen native tool-use guidance in prompts for improved model behavior (PR #10311 by @hannesrudolph)
- UX: Account-centric signup flow for improved onboarding experience (PR #10306 by @brunobergher)
## [3.37.0] - 2025-12-22
![3.37.0 Release - Custom Tool Calling](/releases/3.37.0-release.png)
- Add MiniMax M2.1 and improve environment_details handling for Minimax thinking models (PR #10284 by @hannesrudolph)
- Add GLM-4.7 model with thinking mode support for Zai provider (PR #10282 by @hannesrudolph)
- Add experimental custom tool calling - define custom tools that integrate seamlessly with your AI workflow (PR #10083 by @cte)
- Deprecate XML tool protocol selection and force native tool format for new tasks (PR #10281 by @daniel-lxs)
- Fix: Emit tool_call_end events in OpenAI handler when streaming ends (#10275 by @torxeon, PR #10280 by @daniel-lxs)
- Fix: Emit tool_call_end events in BaseOpenAiCompatibleProvider (PR #10293 by @hannesrudolph)
- Fix: Disable strict mode for MCP tools to preserve optional parameters (PR #10220 by @daniel-lxs)
- Fix: Move array-specific properties into anyOf variant in normalizeToolSchema (PR #10276 by @daniel-lxs)
- Fix: Add CRLF line ending normalization to search_replace and search_and_replace tools (PR #10288 by @hannesrudolph)
- Fix: Add graceful fallback for model parsing in Chutes provider (PR #10279 by @hannesrudolph)
- Fix: Enable Requesty refresh models with credentials (PR #10273 by @daniel-lxs)
- Fix: Improve reasoning_details accumulation and serialization (PR #10285 by @hannesrudolph)
- Fix: Preserve reasoning_content in condense summary for DeepSeek-reasoner (PR #10292 by @hannesrudolph)
- Refactor Zai provider to merge environment_details into tool result instead of system message (PR #10289 by @hannesrudolph)
- Remove parallel_tool_calls parameter from litellm provider (PR #10274 by @roomote)
- Add Cloud Team page with comprehensive team management features (PR #10267 by @roomote)
- Add message log deduper utility for evals (PR #10286 by @hannesrudolph)
## [3.36.16] - 2025-12-19
- Fix: Normalize tool schemas for VS Code LM API to resolve error 400 when using VS Code Language Model API providers (PR #10221 by @hannesrudolph)

View file

@ -69,7 +69,7 @@ Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) •
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Installing Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configuring Profiles</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Codebase Indexing</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Custom Modes</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Todo Lists</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Custom Modes</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Context Management</b> |
</div>
<p align="center">

231
apps/cli/README.md Normal file
View file

@ -0,0 +1,231 @@
# @roo-code/cli
Command Line Interface for Roo Code - Run the Roo Code agent from the terminal without VSCode.
## Overview
This CLI uses the `@roo-code/vscode-shim` package to provide a VSCode API compatibility layer, allowing the main Roo Code extension to run in a Node.js environment.
## Installation
### Quick Install (Recommended)
Install the Roo Code CLI with a single command:
```bash
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
```
**Requirements:**
- Node.js 20 or higher
- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64)
**Custom installation directory:**
```bash
ROO_INSTALL_DIR=/opt/roo-code ROO_BIN_DIR=/usr/local/bin curl -fsSL ... | sh
```
**Install a specific version:**
```bash
ROO_VERSION=0.1.0 curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
```
### Updating
Re-run the install script to update to the latest version:
```bash
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
```
### Uninstalling
```bash
rm -rf ~/.roo/cli ~/.local/bin/roo
```
### Development Installation
For contributing or development:
```bash
# From the monorepo root.
pnpm install
# Build the main extension first.
pnpm --filter roo-cline bundle
# Build the cli.
pnpm --filter @roo-code/cli build
```
## Usage
### Interactive Mode (Default)
By default, the CLI prompts for approval before executing actions:
```bash
export OPENROUTER_API_KEY=sk-or-v1-...
roo "What is this project?" --workspace ~/Documents/my-project
```
In interactive mode:
- Tool executions prompt for yes/no approval
- Commands prompt for yes/no approval
- Followup questions show suggestions and wait for user input
- Browser and MCP actions prompt for approval
### Non-Interactive Mode (`-y`)
For automation and scripts, use `-y` to auto-approve all actions:
```bash
roo -y "Refactor the utils.ts file" --workspace ~/Documents/my-project
```
In non-interactive mode:
- Tool, command, browser, and MCP actions are auto-approved
- Followup questions show a 10-second timeout, then auto-select the first suggestion
- Typing any key cancels the timeout and allows manual input
## Options
| Option | Description | Default |
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` |
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
| `-m, --model <model>` | Model to use | Provider default |
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (none, minimal, low, medium, high, xhigh) | `medium` |
By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information.
## Environment Variables
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` |
| mistral | `MISTRAL_API_KEY` |
| deepseek | `DEEPSEEK_API_KEY` |
| bedrock | `AWS_ACCESS_KEY_ID` |
## Architecture
```
┌─────────────────┐
│ CLI Entry │
│ (index.ts) │
└────────┬────────┘
┌─────────────────┐
│ ExtensionHost │
│ (extension- │
│ host.ts) │
└────────┬────────┘
┌────┴────┐
│ │
▼ ▼
┌───────┐ ┌──────────┐
│vscode │ │Extension │
│-shim │ │ Bundle │
└───────┘ └──────────┘
```
## How It Works
1. **CLI Entry Point** (`index.ts`): Parses command line arguments and initializes the ExtensionHost
2. **ExtensionHost** (`extension-host.ts`):
- Creates a VSCode API mock using `@roo-code/vscode-shim`
- Intercepts `require('vscode')` to return the mock
- Loads and activates the extension bundle
- Manages bidirectional message flow
3. **Message Flow**:
- CLI → Extension: `emit("webviewMessage", {...})`
- Extension → CLI: `emit("extensionWebviewMessage", {...})`
## Current Limitations
- **No TUI**: Output is plain text (no React/Ink UI yet)
- **No configuration file**: Settings are passed via command line flags
- **No persistence**: Each run is a fresh session
## Development
```bash
# Watch mode for development
pnpm dev
# Run tests
pnpm test
# Type checking
pnpm check-types
# Linting
pnpm lint
```
## Releasing
To create a new release, run the release script from the monorepo root:
```bash
# Release using version from package.json
./apps/cli/scripts/release.sh
# Release with a specific version
./apps/cli/scripts/release.sh 0.1.0
```
The script will:
1. Build the extension and CLI
2. Create a platform-specific tarball (for your current OS/architecture)
3. Create a GitHub release with the tarball attached
**Prerequisites:**
- GitHub CLI (`gh`) installed and authenticated (`gh auth login`)
- pnpm installed
## Troubleshooting
### Extension bundle not found
Make sure you've built the main extension first:
```bash
cd src
pnpm bundle
```
### Module resolution errors
The CLI expects the extension to be a CommonJS bundle. Make sure the extension's esbuild config outputs CommonJS.
### "vscode" module not found
The CLI intercepts `require('vscode')` calls. If you see this error, the module resolution interception may have failed.

View file

@ -0,0 +1,4 @@
import { config } from "@roo-code/config-eslint/base"
/** @type {import("eslint").Linter.Config} */
export default [...config]

287
apps/cli/install.sh Executable file
View file

@ -0,0 +1,287 @@
#!/bin/sh
# Roo Code CLI Installer
# Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
#
# Environment variables:
# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli)
# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin)
# ROO_VERSION - Specific version to install (default: latest)
set -e
# Configuration
INSTALL_DIR="${ROO_INSTALL_DIR:-$HOME/.roo/cli}"
BIN_DIR="${ROO_BIN_DIR:-$HOME/.local/bin}"
REPO="RooCodeInc/Roo-Code"
MIN_NODE_VERSION=20
# Color output (only if terminal supports it)
if [ -t 1 ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'
else
RED=''
GREEN=''
YELLOW=''
BLUE=''
BOLD=''
NC=''
fi
info() { printf "${GREEN}==>${NC} %s\n" "$1"; }
warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; }
error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; }
# Check Node.js version
check_node() {
if ! command -v node >/dev/null 2>&1; then
error "Node.js is not installed. Please install Node.js $MIN_NODE_VERSION or higher.
Install Node.js:
- macOS: brew install node
- Linux: https://nodejs.org/en/download/package-manager
- Or use a version manager like fnm, nvm, or mise"
fi
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_VERSION" -lt "$MIN_NODE_VERSION" ]; then
error "Node.js $MIN_NODE_VERSION+ required. Found: $(node -v)
Please upgrade Node.js to version $MIN_NODE_VERSION or higher."
fi
info "Found Node.js $(node -v)"
}
# Detect OS and architecture
detect_platform() {
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
darwin) OS="darwin" ;;
linux) OS="linux" ;;
mingw*|msys*|cygwin*)
error "Windows is not supported by this installer. Please use WSL or install manually."
;;
*) error "Unsupported OS: $OS" ;;
esac
case "$ARCH" in
x86_64|amd64) ARCH="x64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) error "Unsupported architecture: $ARCH" ;;
esac
PLATFORM="${OS}-${ARCH}"
info "Detected platform: $PLATFORM"
}
# Get latest release version or use specified version
get_version() {
if [ -n "$ROO_VERSION" ]; then
VERSION="$ROO_VERSION"
info "Using specified version: $VERSION"
return
fi
info "Fetching latest version..."
# Try to get the latest cli release
RELEASES_JSON=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" 2>/dev/null) || {
error "Failed to fetch releases from GitHub. Check your internet connection."
}
# Extract the latest cli-v* tag
VERSION=$(echo "$RELEASES_JSON" |
grep -o '"tag_name": "cli-v[^"]*"' |
head -1 |
sed 's/"tag_name": "cli-v//' |
sed 's/"//')
if [ -z "$VERSION" ]; then
error "Could not find any CLI releases. The CLI may not have been released yet."
fi
info "Latest version: $VERSION"
}
# Download and extract
download_and_install() {
TARBALL="roo-cli-${PLATFORM}.tar.gz"
URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}"
info "Downloading from $URL..."
# Create temp directory
TMP_DIR=$(mktemp -d)
trap "rm -rf $TMP_DIR" EXIT
# Download with progress indicator
HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || {
if [ "$HTTP_CODE" = "404" ]; then
error "Release not found for platform $PLATFORM version $VERSION.
Available at: https://github.com/$REPO/releases"
fi
error "Download failed. HTTP code: $HTTP_CODE"
}
# Verify we got something
if [ ! -s "$TMP_DIR/$TARBALL" ]; then
error "Downloaded file is empty. Please try again."
fi
# Remove old installation if exists
if [ -d "$INSTALL_DIR" ]; then
info "Removing previous installation..."
rm -rf "$INSTALL_DIR"
fi
mkdir -p "$INSTALL_DIR"
# Extract
info "Extracting to $INSTALL_DIR..."
tar -xzf "$TMP_DIR/$TARBALL" -C "$INSTALL_DIR" --strip-components=1 || {
error "Failed to extract tarball. The download may be corrupted."
}
# Save ripgrep binary before npm install (npm install will overwrite node_modules)
RIPGREP_BIN=""
if [ -f "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" ]; then
RIPGREP_BIN="$TMP_DIR/rg"
cp "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" "$RIPGREP_BIN"
fi
# Install npm dependencies
info "Installing dependencies..."
cd "$INSTALL_DIR"
npm install --production --silent 2>/dev/null || {
warn "npm install failed, trying with --legacy-peer-deps..."
npm install --production --legacy-peer-deps --silent 2>/dev/null || {
error "Failed to install dependencies. Make sure npm is available."
}
}
cd - > /dev/null
# Restore ripgrep binary after npm install
if [ -n "$RIPGREP_BIN" ] && [ -f "$RIPGREP_BIN" ]; then
mkdir -p "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin"
cp "$RIPGREP_BIN" "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg"
chmod +x "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg"
fi
# Make executable
chmod +x "$INSTALL_DIR/bin/roo"
# Also make ripgrep executable if it exists
if [ -f "$INSTALL_DIR/bin/rg" ]; then
chmod +x "$INSTALL_DIR/bin/rg"
fi
}
# Create symlink in bin directory
setup_bin() {
mkdir -p "$BIN_DIR"
# Remove old symlink if exists
if [ -L "$BIN_DIR/roo" ] || [ -f "$BIN_DIR/roo" ]; then
rm -f "$BIN_DIR/roo"
fi
ln -sf "$INSTALL_DIR/bin/roo" "$BIN_DIR/roo"
info "Created symlink: $BIN_DIR/roo"
}
# Check if bin dir is in PATH and provide instructions
check_path() {
case ":$PATH:" in
*":$BIN_DIR:"*)
# Already in PATH
return 0
;;
esac
warn "$BIN_DIR is not in your PATH"
echo ""
echo "Add this line to your shell profile:"
echo ""
# Detect shell and provide specific instructions
SHELL_NAME=$(basename "$SHELL")
case "$SHELL_NAME" in
zsh)
echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.zshrc"
echo " source ~/.zshrc"
;;
bash)
if [ -f "$HOME/.bashrc" ]; then
echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc"
echo " source ~/.bashrc"
else
echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bash_profile"
echo " source ~/.bash_profile"
fi
;;
fish)
echo " set -Ux fish_user_paths $BIN_DIR \$fish_user_paths"
;;
*)
echo " export PATH=\"$BIN_DIR:\$PATH\""
;;
esac
echo ""
}
# Verify installation
verify_install() {
if [ -x "$BIN_DIR/roo" ]; then
info "Verifying installation..."
# Just check if it runs without error
"$BIN_DIR/roo" --version >/dev/null 2>&1 || true
fi
}
# Print success message
print_success() {
echo ""
printf "${GREEN}${BOLD}✓ Roo Code CLI installed successfully!${NC}\n"
echo ""
echo " Installation: $INSTALL_DIR"
echo " Binary: $BIN_DIR/roo"
echo " Version: $VERSION"
echo ""
echo " ${BOLD}Get started:${NC}"
echo " roo --help"
echo ""
echo " ${BOLD}Example:${NC}"
echo " export OPENROUTER_API_KEY=sk-or-v1-..."
echo " roo \"What is this project?\" --workspace ~/my-project"
echo ""
}
# Main
main() {
echo ""
printf "${BLUE}${BOLD}"
echo " ╭─────────────────────────────────╮"
echo " │ Roo Code CLI Installer │"
echo " ╰─────────────────────────────────╯"
printf "${NC}"
echo ""
check_node
detect_platform
get_version
download_and_install
setup_bin
check_path
verify_install
print_success
}
main "$@"

35
apps/cli/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "@roo-code/cli",
"version": "0.1.0",
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
"private": true,
"type": "module",
"main": "dist/index.js",
"bin": {
"roo": "dist/index.js"
},
"scripts": {
"format": "prettier --write 'src/**/*.ts'",
"lint": "eslint src --ext .ts --max-warnings=0",
"check-types": "tsc --noEmit",
"test": "vitest run",
"build": "tsup",
"start": "node dist/index.js",
"clean": "rimraf dist .turbo"
},
"dependencies": {
"@roo-code/types": "workspace:^",
"@roo-code/vscode-shim": "workspace:^",
"@vscode/ripgrep": "^1.15.9",
"commander": "^12.1.0"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "^24.1.0",
"rimraf": "^6.0.1",
"tsup": "^8.4.0",
"typescript": "5.8.3",
"vitest": "^3.2.3"
}
}

363
apps/cli/scripts/release.sh Executable file
View file

@ -0,0 +1,363 @@
#!/bin/bash
# Roo Code CLI Release Script
#
# Usage:
# ./apps/cli/scripts/release.sh [version]
#
# Examples:
# ./apps/cli/scripts/release.sh # Use version from package.json
# ./apps/cli/scripts/release.sh 0.1.0 # Specify version
#
# This script:
# 1. Builds the extension and CLI
# 2. Creates a tarball for the current platform
# 3. Creates a GitHub release and uploads the tarball
#
# Prerequisites:
# - GitHub CLI (gh) installed and authenticated
# - pnpm installed
# - Run from the monorepo root directory
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'
info() { printf "${GREEN}==>${NC} %s\n" "$1"; }
warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; }
error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; }
step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; }
# Get script directory and repo root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
CLI_DIR="$REPO_ROOT/apps/cli"
# Detect current platform
detect_platform() {
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
darwin) OS="darwin" ;;
linux) OS="linux" ;;
*) error "Unsupported OS: $OS" ;;
esac
case "$ARCH" in
x86_64|amd64) ARCH="x64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) error "Unsupported architecture: $ARCH" ;;
esac
PLATFORM="${OS}-${ARCH}"
}
# Check prerequisites
check_prerequisites() {
step "1/7" "Checking prerequisites..."
if ! command -v gh &> /dev/null; then
error "GitHub CLI (gh) is not installed. Install it with: brew install gh"
fi
if ! gh auth status &> /dev/null; then
error "GitHub CLI is not authenticated. Run: gh auth login"
fi
if ! command -v pnpm &> /dev/null; then
error "pnpm is not installed."
fi
if ! command -v node &> /dev/null; then
error "Node.js is not installed."
fi
info "Prerequisites OK"
}
# Get version
get_version() {
if [ -n "$1" ]; then
VERSION="$1"
else
VERSION=$(node -p "require('$CLI_DIR/package.json').version")
fi
# Validate semver format
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
error "Invalid version format: $VERSION (expected semver like 0.1.0)"
fi
TAG="cli-v$VERSION"
info "Version: $VERSION (tag: $TAG)"
}
# Build everything
build() {
step "2/7" "Building extension bundle..."
cd "$REPO_ROOT"
pnpm bundle
step "3/7" "Building CLI..."
pnpm --filter @roo-code/cli build
info "Build complete"
}
# Create release tarball
create_tarball() {
step "4/7" "Creating release tarball for $PLATFORM..."
RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}"
TARBALL="roo-cli-${PLATFORM}.tar.gz"
# Clean up any previous build
rm -rf "$RELEASE_DIR"
rm -f "$REPO_ROOT/$TARBALL"
# Create directory structure
mkdir -p "$RELEASE_DIR/bin"
mkdir -p "$RELEASE_DIR/lib"
mkdir -p "$RELEASE_DIR/extension"
# Copy CLI dist files
info "Copying CLI files..."
cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/"
# Create package.json for npm install (only runtime dependencies)
info "Creating package.json..."
node -e "
const pkg = require('$CLI_DIR/package.json');
const newPkg = {
name: '@roo-code/cli',
version: pkg.version,
type: 'module',
dependencies: {
commander: pkg.dependencies.commander
}
};
console.log(JSON.stringify(newPkg, null, 2));
" > "$RELEASE_DIR/package.json"
# Copy extension bundle
info "Copying extension bundle..."
cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/"
# Add package.json to extension directory to mark it as CommonJS
# This is necessary because the main package.json has "type": "module"
# but the extension bundle is CommonJS
echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json"
# Find and copy ripgrep binary
# The extension looks for ripgrep at: appRoot/node_modules/@vscode/ripgrep/bin/rg
# The CLI sets appRoot to the CLI package root, so we need to put ripgrep there
info "Looking for ripgrep binary..."
RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1)
if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then
info "Found ripgrep at: $RIPGREP_PATH"
# Create the expected directory structure for the extension to find ripgrep
mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin"
cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/"
chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg"
# Also keep a copy in bin/ for direct access
mkdir -p "$RELEASE_DIR/bin"
cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/"
chmod +x "$RELEASE_DIR/bin/rg"
else
warn "ripgrep binary not found - users will need ripgrep installed"
fi
# Create the wrapper script
info "Creating wrapper script..."
cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF'
#!/usr/bin/env node
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Set environment variables for the CLI
process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');
process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');
// Import and run the actual CLI
await import(join(__dirname, '..', 'lib', 'index.js'));
WRAPPER_EOF
chmod +x "$RELEASE_DIR/bin/roo"
# Create version file
echo "$VERSION" > "$RELEASE_DIR/VERSION"
# Create tarball
info "Creating tarball..."
cd "$REPO_ROOT"
tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")"
# Clean up release directory
rm -rf "$RELEASE_DIR"
# Show size
TARBALL_PATH="$REPO_ROOT/$TARBALL"
TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}')
info "Created: $TARBALL ($TARBALL_SIZE)"
}
# Create checksum
create_checksum() {
step "5/7" "Creating checksum..."
cd "$REPO_ROOT"
if command -v sha256sum &> /dev/null; then
sha256sum "$TARBALL" > "${TARBALL}.sha256"
elif command -v shasum &> /dev/null; then
shasum -a 256 "$TARBALL" > "${TARBALL}.sha256"
else
warn "No sha256sum or shasum found, skipping checksum"
return
fi
info "Checksum: $(cat "${TARBALL}.sha256")"
}
# Check if release already exists
check_existing_release() {
step "6/7" "Checking for existing release..."
if gh release view "$TAG" &> /dev/null; then
warn "Release $TAG already exists"
read -p "Do you want to delete it and create a new one? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
info "Deleting existing release..."
gh release delete "$TAG" --yes
# Also delete the tag if it exists
git tag -d "$TAG" 2>/dev/null || true
git push origin ":refs/tags/$TAG" 2>/dev/null || true
else
error "Aborted. Use a different version or delete the existing release manually."
fi
fi
}
# Create GitHub release
create_release() {
step "7/7" "Creating GitHub release..."
cd "$REPO_ROOT"
RELEASE_NOTES=$(cat << EOF
## Installation
\`\`\`bash
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
\`\`\`
Or install a specific version:
\`\`\`bash
ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
\`\`\`
## Requirements
- Node.js 20 or higher
- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64)
## Usage
\`\`\`bash
# Set your API key
export OPENROUTER_API_KEY=sk-or-v1-...
# Run a task
roo "What is this project?" --workspace ~/my-project
# See all options
roo --help
\`\`\`
## Platform Support
This release includes:
- \`roo-cli-${PLATFORM}.tar.gz\` - Built on $(uname -s) $(uname -m)
> **Note:** Additional platforms will be added as needed. If you need a different platform, please open an issue.
## Checksum
\`\`\`
$(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A")
\`\`\`
EOF
)
# Get the current commit SHA for the release target
COMMIT_SHA=$(git rev-parse HEAD)
info "Creating release at commit: ${COMMIT_SHA:0:8}"
# Create release (gh will create the tag automatically)
info "Creating release..."
RELEASE_FILES="$TARBALL"
if [ -f "${TARBALL}.sha256" ]; then
RELEASE_FILES="$RELEASE_FILES ${TARBALL}.sha256"
fi
gh release create "$TAG" \
--title "Roo Code CLI v$VERSION" \
--notes "$RELEASE_NOTES" \
--prerelease \
--target "$COMMIT_SHA" \
$RELEASE_FILES
info "Release created!"
}
# Cleanup
cleanup() {
info "Cleaning up..."
cd "$REPO_ROOT"
rm -f "$TARBALL" "${TARBALL}.sha256"
}
# Print summary
print_summary() {
echo ""
printf "${GREEN}${BOLD}✓ Release v$VERSION created successfully!${NC}\n"
echo ""
echo " Release URL: https://github.com/RooCodeInc/Roo-Code/releases/tag/$TAG"
echo ""
echo " Install with:"
echo " curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh"
echo ""
}
# Main
main() {
echo ""
printf "${BLUE}${BOLD}"
echo " ╭─────────────────────────────────╮"
echo " │ Roo Code CLI Release Script │"
echo " ╰─────────────────────────────────╯"
printf "${NC}"
echo ""
detect_platform
check_prerequisites
get_version "$1"
build
create_tarball
create_checksum
check_existing_release
create_release
cleanup
print_summary
}
main "$@"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,144 @@
/**
* Integration tests for CLI
*
* These tests require a valid OPENROUTER_API_KEY environment variable.
* They will be skipped if the API key is not available.
*
* Run with: OPENROUTER_API_KEY=sk-or-v1-... pnpm test
*/
import { ExtensionHost } from "../extension-host.js"
import path from "path"
import fs from "fs"
import os from "os"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
const hasApiKey = !!OPENROUTER_API_KEY
// Find the extension path - we need a built extension for integration tests
function findExtensionPath(): string | null {
// From apps/cli/src/__tests__, go up to monorepo root then to src/dist
const monorepoPath = path.resolve(__dirname, "../../../../src/dist")
if (fs.existsSync(path.join(monorepoPath, "extension.js"))) {
return monorepoPath
}
// Also try from the apps/cli level
const altPath = path.resolve(__dirname, "../../../src/dist")
if (fs.existsSync(path.join(altPath, "extension.js"))) {
return altPath
}
return null
}
const extensionPath = findExtensionPath()
const hasExtension = !!extensionPath
// Create a temporary workspace directory for tests
function createTempWorkspace(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-cli-test-"))
return tempDir
}
// Clean up temporary workspace
function cleanupWorkspace(workspacePath: string): void {
try {
fs.rmSync(workspacePath, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
}
describe.skipIf(!hasApiKey || !hasExtension)(
"CLI Integration Tests (requires OPENROUTER_API_KEY and built extension)",
() => {
let workspacePath: string
let host: ExtensionHost
beforeAll(() => {
console.log("Integration tests running with:")
console.log(` - API Key: ${OPENROUTER_API_KEY?.substring(0, 12)}...`)
console.log(` - Extension Path: ${extensionPath}`)
})
beforeEach(() => {
workspacePath = createTempWorkspace()
})
afterEach(async () => {
if (host) {
await host.dispose()
}
cleanupWorkspace(workspacePath)
})
/**
* Main integration test - tests the complete end-to-end flow
*
* NOTE: Due to the extension using singletons (TelemetryService, etc.),
* only one integration test can run per process. This single test covers
* the main functionality: activation, task execution, completion, and disposal.
*/
it("should complete end-to-end task execution with proper lifecycle", async () => {
host = new ExtensionHost({
mode: "code",
apiProvider: "openrouter",
apiKey: OPENROUTER_API_KEY!,
model: "anthropic/claude-haiku-4.5", // Use fast, cheap model for tests.
workspacePath,
extensionPath: extensionPath!,
quiet: true,
})
// Test activation
await host.activate()
// Track state messages
const stateMessages: unknown[] = []
host.on("extensionWebviewMessage", (msg: Record<string, unknown>) => {
if (msg.type === "state") {
stateMessages.push(msg)
}
})
// Test task execution with completion
// Note: runTask internally waits for webview to be ready before sending messages
await expect(host.runTask("Say hello in exactly 5 words")).resolves.toBeUndefined()
// After task completes, webview should have been ready
expect(host.isInInitialSetup()).toBe(false)
// Verify we received state updates
expect(stateMessages.length).toBeGreaterThan(0)
// Test disposal
await host.dispose()
expect((global as Record<string, unknown>).vscode).toBeUndefined()
expect((global as Record<string, unknown>).__extensionHost).toBeUndefined()
}, 120000) // 2 minute timeout
},
)
// Additional test to verify skip behavior
describe("Integration test skip behavior", () => {
it("should have OPENROUTER_API_KEY check", () => {
if (hasApiKey) {
console.log("OPENROUTER_API_KEY is set, integration tests will run")
} else {
console.log("OPENROUTER_API_KEY is not set, integration tests will be skipped")
}
expect(true).toBe(true) // Always passes
})
it("should have extension check", () => {
if (hasExtension) {
console.log(`Extension found at: ${extensionPath}`)
} else {
console.log("Extension not found, integration tests will be skipped")
}
expect(true).toBe(true) // Always passes
})
})

View file

@ -0,0 +1,119 @@
/**
* Unit tests for CLI utility functions
*/
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
import fs from "fs"
import path from "path"
// Mock fs module
vi.mock("fs")
describe("getEnvVarName", () => {
it.each([
["anthropic", "ANTHROPIC_API_KEY"],
["openai", "OPENAI_API_KEY"],
["openrouter", "OPENROUTER_API_KEY"],
["google", "GOOGLE_API_KEY"],
["gemini", "GOOGLE_API_KEY"],
["bedrock", "AWS_ACCESS_KEY_ID"],
["ollama", "OLLAMA_API_KEY"],
["mistral", "MISTRAL_API_KEY"],
["deepseek", "DEEPSEEK_API_KEY"],
])("should return %s for %s provider", (provider, expectedEnvVar) => {
expect(getEnvVarName(provider)).toBe(expectedEnvVar)
})
it("should handle case-insensitive provider names", () => {
expect(getEnvVarName("ANTHROPIC")).toBe("ANTHROPIC_API_KEY")
expect(getEnvVarName("Anthropic")).toBe("ANTHROPIC_API_KEY")
expect(getEnvVarName("OpenRouter")).toBe("OPENROUTER_API_KEY")
})
it("should return uppercase provider name with _API_KEY suffix for unknown providers", () => {
expect(getEnvVarName("custom")).toBe("CUSTOM_API_KEY")
expect(getEnvVarName("myProvider")).toBe("MYPROVIDER_API_KEY")
})
})
describe("getApiKeyFromEnv", () => {
const originalEnv = process.env
beforeEach(() => {
// Reset process.env before each test
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it("should return API key from environment variable for anthropic", () => {
process.env.ANTHROPIC_API_KEY = "test-anthropic-key"
expect(getApiKeyFromEnv("anthropic")).toBe("test-anthropic-key")
})
it("should return API key from environment variable for openrouter", () => {
process.env.OPENROUTER_API_KEY = "test-openrouter-key"
expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key")
})
it("should return API key from environment variable for openai", () => {
process.env.OPENAI_API_KEY = "test-openai-key"
expect(getApiKeyFromEnv("openai")).toBe("test-openai-key")
})
it("should return undefined when API key is not set", () => {
delete process.env.ANTHROPIC_API_KEY
expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
})
it("should handle custom provider names", () => {
process.env.CUSTOM_API_KEY = "test-custom-key"
expect(getApiKeyFromEnv("custom")).toBe("test-custom-key")
})
it("should handle case-insensitive provider lookup", () => {
process.env.ANTHROPIC_API_KEY = "test-key"
expect(getApiKeyFromEnv("ANTHROPIC")).toBe("test-key")
})
})
describe("getDefaultExtensionPath", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("should return monorepo path when extension.js exists there", () => {
const mockDirname = "/test/apps/cli/dist"
const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
vi.mocked(fs.existsSync).mockReturnValue(true)
const result = getDefaultExtensionPath(mockDirname)
expect(result).toBe(expectedMonorepoPath)
expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
})
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")
vi.mocked(fs.existsSync).mockReturnValue(false)
const result = getDefaultExtensionPath(mockDirname)
expect(result).toBe(expectedPackagePath)
})
it("should check monorepo path first", () => {
const mockDirname = "/some/path"
vi.mocked(fs.existsSync).mockReturnValue(false)
getDefaultExtensionPath(mockDirname)
const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
})
})

File diff suppressed because it is too large Load diff

163
apps/cli/src/index.ts Normal file
View file

@ -0,0 +1,163 @@
/**
* @roo-code/cli - Command Line Interface for Roo Code
*/
import { Command } from "commander"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import {
type ProviderName,
type ReasoningEffortExtended,
isProviderName,
reasoningEffortsExtended,
} from "@roo-code/types"
import { setLogger } from "@roo-code/vscode-shim"
import { ExtensionHost } from "./extension-host.js"
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils.js"
const DEFAULTS = {
mode: "code",
reasoningEffort: "medium" as const,
model: "anthropic/claude-sonnet-4.5",
}
const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const program = new Command()
program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version("0.1.0")
program
.argument("<prompt>", "The prompt/task to execute")
.option("-w, --workspace <path>", "Workspace path to operate in", process.cwd())
.option("-e, --extension <path>", "Path to the extension bundle directory")
.option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false)
.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
.option("-x, --exit-on-complete", "Exit the process when the task completes (useful for testing)", 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 ANTHROPIC_API_KEY env var)")
.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", "openrouter")
.option("-m, --model <model>", "Model to use", DEFAULTS.model)
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULTS.mode)
.option(
"-r, --reasoning-effort <effort>",
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
DEFAULTS.reasoningEffort,
)
.action(
async (
prompt: string,
options: {
workspace: string
extension?: string
verbose: boolean
debug: boolean
exitOnComplete: boolean
yes: boolean
apiKey?: string
provider: ProviderName
model?: string
mode?: string
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
},
) => {
// Default is quiet mode - suppress VSCode shim logs unless verbose
// or debug is specified.
if (!options.verbose && !options.debug) {
setLogger({
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
})
}
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
const workspacePath = path.resolve(options.workspace)
if (!apiKey) {
console.error(
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
)
console.error(`[CLI] For ${options.provider}, set ${getEnvVarName(options.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}`)
process.exit(1)
}
if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
console.error(
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
)
process.exit(1)
}
console.log(`[CLI] Mode: ${options.mode || "default"}`)
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
console.log(`[CLI] Provider: ${options.provider}`)
console.log(`[CLI] Model: ${options.model || "default"}`)
console.log(`[CLI] Workspace: ${workspacePath}`)
const host = new ExtensionHost({
mode: options.mode || DEFAULTS.mode,
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
apiProvider: options.provider,
apiKey,
model: options.model || DEFAULTS.model,
workspacePath,
extensionPath: path.resolve(extensionPath),
verbose: options.debug,
quiet: !options.verbose && !options.debug,
nonInteractive: options.yes,
})
// Handle SIGINT (Ctrl+C)
process.on("SIGINT", async () => {
console.log("\n[CLI] Received SIGINT, shutting down...")
await host.dispose()
process.exit(130)
})
// Handle SIGTERM
process.on("SIGTERM", async () => {
console.log("\n[CLI] Received SIGTERM, shutting down...")
await host.dispose()
process.exit(143)
})
try {
await host.activate()
await host.runTask(prompt)
await host.dispose()
if (options.exitOnComplete) {
process.exit(0)
}
} catch (error) {
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
if (options.debug && error instanceof Error) {
console.error(error.stack)
}
await host.dispose()
process.exit(1)
}
},
)
program.parse()

62
apps/cli/src/utils.ts Normal file
View file

@ -0,0 +1,62 @@
/**
* Utility functions for the Roo Code CLI
*/
import path from "path"
import fs from "fs"
/**
* Get the environment variable name for a provider's API key
*/
export function getEnvVarName(provider: string): string {
const envVarMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
google: "GOOGLE_API_KEY",
gemini: "GOOGLE_API_KEY",
bedrock: "AWS_ACCESS_KEY_ID",
ollama: "OLLAMA_API_KEY",
mistral: "MISTRAL_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
}
return envVarMap[provider.toLowerCase()] || `${provider.toUpperCase()}_API_KEY`
}
/**
* Get API key from environment variable based on provider
*/
export function getApiKeyFromEnv(provider: string): string | undefined {
const envVar = getEnvVarName(provider)
return process.env[envVar]
}
/**
* Get the default path to the extension bundle.
* This assumes the CLI is installed alongside the built extension.
*
* @param dirname - The __dirname equivalent for the calling module
*/
export function getDefaultExtensionPath(dirname: string): string {
// Check for environment variable first (set by install script)
if (process.env.ROO_EXTENSION_PATH) {
const envPath = process.env.ROO_EXTENSION_PATH
if (fs.existsSync(path.join(envPath, "extension.js"))) {
return envPath
}
}
// __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")
// 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")
return packagePath
}

9
apps/cli/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "@roo-code/config-typescript/base.json",
"compilerOptions": {
"types": ["vitest/globals"],
"outDir": "dist"
},
"include": ["src", "*.config.ts"],
"exclude": ["node_modules"]
}

24
apps/cli/tsup.config.ts Normal file
View file

@ -0,0 +1,24 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
target: "node20",
platform: "node",
banner: {
js: "#!/usr/bin/env node",
},
// Bundle workspace packages that export TypeScript
noExternal: ["@roo-code/types", "@roo-code/vscode-shim"],
external: [
// Keep native modules external
"@anthropic-ai/sdk",
"@anthropic-ai/bedrock-sdk",
"@anthropic-ai/vertex-sdk",
// Keep @vscode/ripgrep external - we bundle the binary separately
"@vscode/ripgrep",
],
})

11
apps/cli/vitest.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: true,
environment: "node",
watch: false,
testTimeout: 120_000, // 2m for integration tests.
include: ["src/**/*.test.ts"],
},
})

View file

@ -19,10 +19,6 @@ suite("Roo Code Extension", function () {
"openInNewTab",
"settingsButtonClicked",
"historyButtonClicked",
"showHumanRelayDialog",
"registerHumanRelayCallback",
"unregisterHumanRelayCallback",
"handleHumanRelayResponse",
"newTask",
"setCustomStoragePath",
"focusInput",

View file

@ -28,10 +28,18 @@ const EVALS_STORAGE_PATH = "/tmp/evals/runs"
const EVALS_REPO_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../../evals")
export async function createRun({ suite, exercises = [], timeout, iterations = 1, ...values }: CreateRun) {
export async function createRun({
suite,
exercises = [],
timeout,
iterations = 1,
executionMethod = "vscode",
...values
}: CreateRun) {
const run = await _createRun({
...values,
timeout,
executionMethod,
socketPath: "", // TODO: Get rid of this.
})

View file

@ -7,15 +7,26 @@ import { useQuery } from "@tanstack/react-query"
import { useForm, FormProvider } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, Info, Plus, Minus } from "lucide-react"
import {
X,
Rocket,
Check,
ChevronsUpDown,
SlidersHorizontal,
Info,
Plus,
Minus,
Terminal,
MonitorPlay,
} from "lucide-react"
import {
globalSettingsSchema,
providerSettingsSchema,
EVALS_SETTINGS,
getModelId,
type ProviderSettings,
type GlobalSettings,
globalSettingsSchema,
providerSettingsSchema,
getModelId,
EVALS_SETTINGS,
} from "@roo-code/types"
import { createRun } from "@/actions/runs"
@ -23,6 +34,7 @@ import { getExercises } from "@/actions/exercises"
import {
type CreateRun,
type ExecutionMethod,
createRunSchema,
CONCURRENCY_MIN,
CONCURRENCY_MAX,
@ -77,14 +89,12 @@ type ImportedSettings = {
currentApiConfigName: string
}
// Type for a model selection entry
type ModelSelection = {
id: string
model: string
popoverOpen: boolean
}
// Type for a config selection entry (for import mode)
type ConfigSelection = {
id: string
configName: string
@ -95,16 +105,15 @@ export function NewRun() {
const router = useRouter()
const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("other")
const [executionMethod, setExecutionMethod] = useState<ExecutionMethod>("vscode")
const [useNativeToolProtocol, setUseNativeToolProtocol] = useState(true)
const [commandExecutionTimeout, setCommandExecutionTimeout] = useState(20)
const [terminalShellIntegrationTimeout, setTerminalShellIntegrationTimeout] = useState(30) // seconds
// State for multiple model selections
const [modelSelections, setModelSelections] = useState<ModelSelection[]>([
{ id: crypto.randomUUID(), model: "", popoverOpen: false },
])
// State for imported settings with multiple config selections
const [importedSettings, setImportedSettings] = useState<ImportedSettings | null>(null)
const [configSelections, setConfigSelections] = useState<ConfigSelection[]>([
{ id: crypto.randomUUID(), configName: "", popoverOpen: false },
@ -119,7 +128,6 @@ export function NewRun() {
const exercises = useQuery({ queryKey: ["getExercises"], queryFn: () => getExercises() })
// State for selected exercises (needed for language toggle buttons)
const [selectedExercises, setSelectedExercises] = useState<string[]>([])
const form = useForm<CreateRun>({
@ -134,6 +142,7 @@ export function NewRun() {
timeout: TIMEOUT_DEFAULT,
iterations: ITERATIONS_DEFAULT,
jobToken: "",
executionMethod: "vscode",
},
})
@ -146,38 +155,49 @@ export function NewRun() {
const [suite, settings] = watch(["suite", "settings", "concurrency"])
// Load settings from localStorage on mount
useEffect(() => {
const savedConcurrency = localStorage.getItem("evals-concurrency")
if (savedConcurrency) {
const parsed = parseInt(savedConcurrency, 10)
if (!isNaN(parsed) && parsed >= CONCURRENCY_MIN && parsed <= CONCURRENCY_MAX) {
setValue("concurrency", parsed)
}
}
const savedTimeout = localStorage.getItem("evals-timeout")
if (savedTimeout) {
const parsed = parseInt(savedTimeout, 10)
if (!isNaN(parsed) && parsed >= TIMEOUT_MIN && parsed <= TIMEOUT_MAX) {
setValue("timeout", parsed)
}
}
const savedCommandTimeout = localStorage.getItem("evals-command-execution-timeout")
if (savedCommandTimeout) {
const parsed = parseInt(savedCommandTimeout, 10)
if (!isNaN(parsed) && parsed >= 20 && parsed <= 60) {
setCommandExecutionTimeout(parsed)
}
}
const savedShellTimeout = localStorage.getItem("evals-shell-integration-timeout")
if (savedShellTimeout) {
const parsed = parseInt(savedShellTimeout, 10)
if (!isNaN(parsed) && parsed >= 30 && parsed <= 60) {
setTerminalShellIntegrationTimeout(parsed)
}
}
// Load saved exercises selection
const savedSuite = localStorage.getItem("evals-suite")
if (savedSuite === "partial") {
setValue("suite", "partial")
const savedExercises = localStorage.getItem("evals-exercises")
@ -189,48 +209,57 @@ export function NewRun() {
setValue("exercises", parsed)
}
} catch {
// Invalid JSON, ignore
// Invalid JSON, ignore.
}
}
}
}, [setValue])
// Extract unique languages from exercises
const languages = useMemo(() => {
if (!exercises.data) return []
if (!exercises.data) {
return []
}
const langs = new Set<string>()
for (const path of exercises.data) {
const lang = path.split("/")[0]
if (lang) langs.add(lang)
if (lang) {
langs.add(lang)
}
}
return Array.from(langs).sort()
}, [exercises.data])
// Get exercises for a specific language
const getExercisesForLanguage = useCallback(
(lang: string) => {
if (!exercises.data) return []
if (!exercises.data) {
return []
}
return exercises.data.filter((path) => path.startsWith(`${lang}/`))
},
[exercises.data],
)
// Toggle all exercises for a language
const toggleLanguage = useCallback(
(lang: string) => {
const langExercises = getExercisesForLanguage(lang)
const allSelected = langExercises.every((ex) => selectedExercises.includes(ex))
let newSelected: string[]
if (allSelected) {
// Remove all exercises for this language
newSelected = selectedExercises.filter((ex) => !ex.startsWith(`${lang}/`))
} else {
// Add all exercises for this language (avoiding duplicates)
const existing = new Set(selectedExercises)
for (const ex of langExercises) {
existing.add(ex)
}
newSelected = Array.from(existing)
}
@ -241,7 +270,6 @@ export function NewRun() {
[getExercisesForLanguage, selectedExercises, setValue],
)
// Check if all exercises for a language are selected
const isLanguageSelected = useCallback(
(lang: string) => {
const langExercises = getExercisesForLanguage(lang)
@ -250,7 +278,6 @@ export function NewRun() {
[getExercisesForLanguage, selectedExercises],
)
// Check if some (but not all) exercises for a language are selected
const isLanguagePartiallySelected = useCallback(
(lang: string) => {
const langExercises = getExercisesForLanguage(lang)
@ -260,46 +287,40 @@ export function NewRun() {
[getExercisesForLanguage, selectedExercises],
)
// Add a new model selection
const addModelSelection = useCallback(() => {
setModelSelections((prev) => [...prev, { id: crypto.randomUUID(), model: "", popoverOpen: false }])
}, [])
// Remove a model selection
const removeModelSelection = useCallback((id: string) => {
setModelSelections((prev) => prev.filter((s) => s.id !== id))
}, [])
// Update a model selection
const updateModelSelection = useCallback(
(id: string, model: string) => {
setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, model, popoverOpen: false } : s)))
// Also set the form model field for validation (use first non-empty model)
// Also set the form model field for validation (use first non-empty model).
setValue("model", model)
},
[setValue],
)
// Toggle popover for a model selection
const toggleModelPopover = useCallback((id: string, open: boolean) => {
setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s)))
}, [])
// Add a new config selection
const addConfigSelection = useCallback(() => {
setConfigSelections((prev) => [...prev, { id: crypto.randomUUID(), configName: "", popoverOpen: false }])
}, [])
// Remove a config selection
const removeConfigSelection = useCallback((id: string) => {
setConfigSelections((prev) => prev.filter((s) => s.id !== id))
}, [])
// Update a config selection
const updateConfigSelection = useCallback(
(id: string, configName: string) => {
setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, configName, popoverOpen: false } : s)))
// Also update the form settings for the first config (for validation)
// Also update the form settings for the first config (for validation).
if (importedSettings) {
const providerSettings = importedSettings.apiConfigs[configName] ?? {}
setValue("model", getModelId(providerSettings) ?? "")
@ -309,7 +330,6 @@ export function NewRun() {
[importedSettings, setValue],
)
// Toggle popover for a config selection
const toggleConfigPopover = useCallback((id: string, open: boolean) => {
setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s)))
}, [])
@ -317,24 +337,20 @@ export function NewRun() {
const onSubmit = useCallback(
async (values: CreateRun) => {
try {
// Validate jobToken for Roo Code Cloud provider
if (provider === "roo" && !values.jobToken?.trim()) {
toast.error("Roo Code Cloud Token is required")
return
}
// Determine which selections to use based on provider
const selectionsToLaunch: { model: string; configName?: string }[] = []
if (provider === "other") {
// For import mode, use config selections
for (const config of configSelections) {
if (config.configName) {
selectionsToLaunch.push({ model: "", configName: config.configName })
}
}
} else {
// For openrouter/roo, use model selections
for (const selection of modelSelections) {
if (selection.model) {
selectionsToLaunch.push({ model: selection.model })
@ -347,20 +363,19 @@ export function NewRun() {
return
}
// Show launching toast
const totalRuns = selectionsToLaunch.length
toast.info(totalRuns > 1 ? `Launching ${totalRuns} runs (every 20 seconds)...` : "Launching run...")
// Launch runs with 20-second delay between each
for (let i = 0; i < selectionsToLaunch.length; i++) {
const selection = selectionsToLaunch[i]!
// Wait 20 seconds between runs (except for the first one)
// Wait 20 seconds between runs (except for the first one).
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, 20000))
await new Promise((resolve) => setTimeout(resolve, 20_000))
}
const runValues = { ...values }
runValues.executionMethod = executionMethod
if (provider === "openrouter") {
runValues.model = selection.model
@ -403,7 +418,6 @@ export function NewRun() {
}
}
// Navigate back to main evals UI
router.push("/")
} catch (e) {
toast.error(e instanceof Error ? e.message : "An unknown error occurred.")
@ -411,6 +425,7 @@ export function NewRun() {
},
[
provider,
executionMethod,
modelSelections,
configSelections,
importedSettings,
@ -442,18 +457,15 @@ export function NewRun() {
})
.parse(JSON.parse(await file.text()))
// Store all imported configs for user selection
setImportedSettings({
apiConfigs: providerProfiles.apiConfigs,
globalSettings,
currentApiConfigName: providerProfiles.currentApiConfigName,
})
// Default to the current config for the first selection
const defaultConfigName = providerProfiles.currentApiConfigName
setConfigSelections([{ id: crypto.randomUUID(), configName: defaultConfigName, popoverOpen: false }])
// Apply the default config
const providerSettings = providerProfiles.apiConfigs[defaultConfigName] ?? {}
setValue("model", getModelId(providerSettings) ?? "")
setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...globalSettings })
@ -971,6 +983,36 @@ export function NewRun() {
</FormItem>
</div>
{/* Execution Method */}
<FormField
control={form.control}
name="executionMethod"
render={() => (
<FormItem>
<FormLabel>Execution Method</FormLabel>
<Tabs
value={executionMethod}
onValueChange={(value) => {
const newExecutionMethod = value as ExecutionMethod
setExecutionMethod(newExecutionMethod)
setValue("executionMethod", newExecutionMethod)
}}>
<TabsList>
<TabsTrigger value="vscode" className="flex items-center gap-2">
<MonitorPlay className="size-4" />
VSCode
</TabsTrigger>
<TabsTrigger value="cli" className="flex items-center gap-2">
<Terminal className="size-4" />
CLI
</TabsTrigger>
</TabsList>
</Tabs>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"

View file

@ -2,6 +2,13 @@ import { z } from "zod"
import { rooCodeSettingsSchema } from "@roo-code/types"
/**
* ExecutionMethod
*/
export const executionMethodSchema = z.enum(["vscode", "cli"])
export type ExecutionMethod = z.infer<typeof executionMethodSchema>
/**
* CreateRun
*/
@ -29,6 +36,7 @@ export const createRunSchema = z
timeout: z.number().int().min(TIMEOUT_MIN).max(TIMEOUT_MAX),
iterations: z.number().int().min(ITERATIONS_MIN).max(ITERATIONS_MAX),
jobToken: z.string().optional(),
executionMethod: executionMethodSchema,
})
.refine((data) => data.suite === "full" || (data.exercises || []).length > 0, {
message: "Exercises are required when running a partial suite.",

View file

@ -0,0 +1,297 @@
import {
ArrowRight,
Users,
Settings,
BarChart3,
Lock,
Puzzle,
ShieldCheck,
DollarSign,
Share2,
LucideIcon,
Server,
ServerIcon,
RefreshCcw,
} from "lucide-react"
import type { Metadata } from "next"
import { Button } from "@/components/ui"
import { AnimatedBackground } from "@/components/homepage"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { EXTERNAL_LINKS } from "@/lib/constants"
const TITLE = "Roo Code Cloud Team Plan"
const DESCRIPTION =
"Scale your development with team collaboration features. Centralized billing, shared configuration, team-wide analytics, and unified GitHub and Slack integrations."
const OG_DESCRIPTION = "Team collaboration for AI-powered development"
const PATH = "/cloud/team"
export const metadata: Metadata = {
title: TITLE,
description: DESCRIPTION,
alternates: {
canonical: `${SEO.url}${PATH}`,
},
openGraph: {
title: TITLE,
description: DESCRIPTION,
url: `${SEO.url}${PATH}`,
siteName: SEO.name,
images: [
{
url: ogImageUrl(TITLE, OG_DESCRIPTION),
width: 1200,
height: 630,
alt: TITLE,
},
],
locale: SEO.locale,
type: "website",
},
twitter: {
card: SEO.twitterCard,
title: TITLE,
description: DESCRIPTION,
images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
},
keywords: [
...SEO.keywords,
"team",
"collaboration",
"enterprise",
"organization",
"centralized billing",
"team management",
],
}
const keyBenefits = [
{
title: "No Per-Seat Costs",
description: "Add unlimited team members without worrying about escalating per-seat charges.",
icon: Users,
},
{
title: "Centralized Billing",
description:
"Single billing point for all team members using Cloud Agents and the Roo Code Cloud Provider. No more API key management.",
icon: DollarSign,
},
{
title: "Unified Integrations",
description:
"Connect GitHub and Slack once for the entire team. No need for each member to set up individual integrations.",
icon: Settings,
},
{
title: "Team-Wide Visibility",
description: "Access task history and usage analytics across your entire team with granular per-user filters.",
icon: BarChart3,
},
{
title: "Configuration Enforcement",
description:
"Set policies for providers, models, and MCP servers to ensure your team follows organizational standards.",
icon: ShieldCheck,
},
{
title: "Secure Environment Variables",
description:
"Centrally manage secrets, API keys, and environment variables for Cloud Agents in our encrypted secret store.",
icon: Lock,
},
]
interface Feature {
icon: LucideIcon
title: string
description: string
}
const features: Feature[] = [
{
icon: ShieldCheck,
title: "Configuration Enforcement",
description:
"Require team members to log in to the VS Code Extension so policies can be enforced via MDM distribution.",
},
{
icon: Server,
title: "Provider Management",
description:
"Configure and manage the model providers your team can access for both the Extension and Cloud Agents, with API-key-free management.",
},
{
icon: Puzzle,
title: "Centralized Integration",
description:
"Centralized GitHub and Slack connection for the entire team. Agents can review PRs, collaborate on your repositories and respond on your team channels.",
},
{
icon: RefreshCcw,
title: "Extension Task Sync Config",
description:
"Require task syncing from VS Code Extension and control visibility settings for who can view each other's tasks.",
},
{
icon: Share2,
title: "Task Sharing Controls",
description: "Enable per-task sharing with customizable audience controls and link expiration times.",
},
{
icon: ServerIcon,
title: "MCP Server Controls",
description: "Control access to the Roo MCP Marketplace and what custom MCPs to make available to your team.",
},
]
export default function CloudTeamPage() {
return (
<>
{/* Hero Section */}
<section className="relative flex pt-32 pb-20 items-center overflow-hidden">
<AnimatedBackground />
<div className="container relative flex flex-col items-center h-full z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-4xl mx-auto mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-6 md:text-6xl">Roo Code Cloud Team</h1>
<h2 className="text-2xl font-bold tracking-tight mb-6 md:text-4xl">
Built for <span className="text-violet-500">AI-Forward Teams</span>
</h2>
<p className="text-xl text-muted-foreground mb-8 mx-auto">
Empower your entire team with confidence with team-wide configuration, centralized billing,
analytics and more. No per-seat costs, no API key juggling.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="xl"
className="bg-violet-600 hover:bg-violet-700 text-white transition-all duration-300 shadow-lg hover:shadow-violet-500/25"
asChild>
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP + "?redirect_url=/checkout/team"}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center">
Start 14-Day Free Trial
<ArrowRight className="ml-2 size-5" />
</a>
</Button>
<Button variant="outline" size="xl" className="backdrop-blur-sm" asChild>
<a href="/pricing" className="flex items-center justify-center">
View Pricing
</a>
</Button>
</div>
</div>
</div>
</section>
{/* Key Benefits Section */}
<section className="relative overflow-hidden border-t border-border py-32">
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
<div className="absolute left-1/2 top-1/2 h-[400px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/10 dark:bg-blue-700/20 blur-[140px]" />
</div>
<div className="mx-auto mb-12 md:mb-24 max-w-5xl text-center">
<div>
<h2 className="text-4xl font-bold tracking-tight mb-4">Why Teams Choose Roo</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Streamline collaboration and scale your development capacity with team-first features.
</p>
</div>
</div>
<div className="relative mx-auto md:max-w-[1200px]">
<ul className="grid grid-cols-1 place-items-center gap-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-6">
{keyBenefits.map((benefit, index) => {
const Icon = benefit.icon
return (
<li
key={index}
className="rounded-3xl bg-card outline outline-border/50 hover:outline-8 shadow-xl p-8 h-full w-full group transition-all hover:shadow-2xl hover:shadow-violet-800/30 relative">
{Icon && (
<div className="size-15 p-3 rounded-full flex items-center justify-center shadow-lg absolute -top-4 -right-2 transition-all outline outline-foreground/5 bg-card group-hover:outline-3 group-hover:scale-105">
<Icon className="size-5 text-violet-500" />
</div>
)}
<h3 className="text-xl font-bold tracking-tight mb-2">{benefit.title}</h3>
<div className="text-sm text-muted-foreground leading-relaxed">
{benefit.description}
</div>
</li>
)
})}
</ul>
</div>
</div>
</section>
{/* Features Grid */}
<section className="py-24 bg-muted/30">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative">
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
<div className="absolute left-1/2 top-1/2 h-[800px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-violet-500/10 dark:bg-violet-700/20 blur-[140px]" />
</div>
<div className="text-center mb-16">
<h2 className="text-4xl font-bold tracking-tight mb-4">Complete Team Management</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Access all capabilities from your Organization Settings. Everything you need to manage your
team in one place.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto relative">
{features.map((feature, index) => {
const Icon = feature.icon
return (
<div
key={index}
className="relative h-full border border-border rounded-2xl bg-background p-8 transition-all duration-300">
<Icon className="size-6 text-foreground/80" />
<h3 className="mb-3 mt-3 text-xl font-semibold text-foreground">{feature.title}</h3>
<p className="leading-relaxed font-light text-muted-foreground">
{feature.description}
</p>
</div>
)
})}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-24">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl rounded-3xl border border-border/50 bg-gradient-to-br from-violet-500/10 via-purple-500/5 to-blue-500/5 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-white/10 sm:p-16">
<h2 className="mb-6 text-3xl font-bold tracking-tight sm:text-4xl">
Ready to scale your team&apos;s development?
</h2>
<p className="mx-auto mb-10 max-w-2xl text-lg text-muted-foreground">
Start your free 14-day trial today. Got questions? Get in touch.
</p>
<div className="flex flex-col justify-center space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
<Button
size="lg"
className="bg-foreground text-background hover:bg-foreground/90 transition-all duration-300"
asChild>
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP + "?redirect_url=/checkout/team"}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center">
Start Free Trial
<ArrowRight className="ml-2 h-4 w-4" />
</a>
</Button>
<Button variant="outline" size="lg" className="backdrop-blur-sm" asChild>
<a href={EXTERNAL_LINKS.SUPPORT} className="flex items-center justify-center">
Contact Support
</a>
</Button>
</div>
</div>
</div>
</section>
</>
)
}

View file

@ -71,6 +71,7 @@ interface PricingTier {
text: string
href?: string
}
learnMoreLink?: string
}
const pricingTiers: PricingTier[] = [
@ -123,6 +124,7 @@ const pricingTiers: PricingTier[] = [
text: "Sign up",
href: EXTERNAL_LINKS.CLOUD_APP_SIGNUP + "?redirect_url=/billing",
},
learnMoreLink: "/cloud/team",
},
]
@ -164,11 +166,11 @@ export default function PricingPage() {
<Icon className="size-6" strokeWidth={1.5} />
</div>
<div className="grow mb-8">
<div className="grow mb-8 md:h-[214px]">
<p className="text-sm text-muted-foreground font-light mb-2">
{tier.featuresIntro}&nbsp;
</p>
<ul className="space-y-3 my-0 md:h-[192px]">
<ul className="space-y-3 my-0">
{tier.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-0.5 h-4 w-4 text-muted-foreground shrink-0" />
@ -176,6 +178,15 @@ export default function PricingPage() {
</li>
))}
</ul>
{tier.learnMoreLink && (
<div className="mt-2">
<Link
href={tier.learnMoreLink}
className="text-sm text-violet-600 dark:text-violet-400 hover:underline">
Learn more
</Link>
</div>
)}
</div>
<p className="text-base font-light">{tier.trial}</p>

View file

@ -283,7 +283,8 @@ export default function Privacy() {
</li>
<li>
<strong>Delete your Cloud account</strong> at any time from{" "}
<strong>Security Settings</strong> inside Roo Code Cloud.
<strong>Security Settings</strong> inside Roo Code Cloud (User Menu &rarr; My Settings
&rarr; Open Profile).
</li>
<li>
<strong>Marketing communications:</strong> You can unsubscribe from marketing and

View file

@ -70,11 +70,6 @@ export function NavBar({ stars, downloads }: NavBarProps) {
</button>
{/* Dropdown Menu */}
<div className="absolute left-0 top-12 mt-2 w-40 rounded-md border border-border bg-background py-1 shadow-lg opacity-0 -translate-y-2 pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto transition-all duration-200">
<ScrollButton
targetId="faq"
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">
FAQ
</ScrollButton>
<Link
href="/evals"
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">

View file

@ -181,7 +181,7 @@ export function PillarsSection() {
<div className="text-muted-foreground my-4 space-y-1">
<p>
The Roo Code Extension is{" "}
<Link target="_blank" href="https://github.com/Roo-Code-Inc/Roo-Code">
<Link target="_blank" href="https://github.com/RooCodeInc/Roo-Code">
open source
</Link>{" "}
so you can see for yourself exactly what it&apos;s doing and we don&apos;t use

View file

@ -28,6 +28,7 @@ export const EXTERNAL_LINKS = {
CLOUD_APP_SIGNUP: "https://app.roocode.com/sign-up",
CLOUD_APP_SIGNUP_HOME: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/setup",
CLOUD_APP_SIGNUP_PRO: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/setup",
SUPPORT: "mailto:support@roocode.com",
}
export const INTERNAL_LINKS = {

2
locales/ca/README.md generated
View file

@ -69,7 +69,7 @@ Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-mode
| | | |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Instal·lant Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurant perfils</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indexació de la base de codi</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modes personalitzats</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Punts de control</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Llistes de tasques</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modes personalitzats</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Punts de control</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestió de Context</b> |
</div>
<p align="center">

2
locales/de/README.md generated
View file

@ -69,7 +69,7 @@ Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes
| | | |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Code installieren</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Profile konfigurieren</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Codebasis-Indizierung</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Benutzerdefinierte Modi</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Todo-Listen</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Benutzerdefinierte Modi</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Kontextverwaltung</b> |
</div>
<p align="center">

2
locales/es/README.md generated
View file

@ -69,7 +69,7 @@ Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [M
| | | |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Instalando Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurando perfiles</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indexación de la base de código</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modos personalizados</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Listas de Tareas</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modos personalizados</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestión de Contexto</b> |
</div>
<p align="center">

2
locales/fr/README.md generated
View file

@ -69,7 +69,7 @@ En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Installer Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurer les profils</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indexation de la base de code</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modes personnalisés</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Listes de tâches</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modes personnalisés</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestion du Contexte</b> |
</div>
<p align="center">

2
locales/hi/README.md generated
View file

@ -69,7 +69,7 @@
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>रू कोड इंस्टॉल करना</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>प्रोफाइल कॉन्फ़िगर करना</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>कोडबेस इंडेक्सिंग</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>कस्टम मोड</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>चेकपॉइंट्स</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>टू-डू लिस्ट</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>कस्टम मोड</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>चेकपॉइंट्स</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>संदर्भ प्रबंधन</b> |
</div>
<p align="center">

2
locales/id/README.md generated
View file

@ -69,7 +69,7 @@ Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/u
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Menginstal Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Mengonfigurasi Profil</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Pengindeksan Basis Kode</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Mode Kustom</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Pos Pemeriksaan</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Daftar Tugas</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Mode Kustom</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Pos Pemeriksaan</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Manajemen Konteks</b> |
</div>
<p align="center">

2
locales/it/README.md generated
View file

@ -69,7 +69,7 @@ Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Installazione di Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurazione dei profili</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indicizzazione della codebase</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modalità personalizzate</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoint</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Elenchi di cose da fare</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modalità personalizzate</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoint</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestione del Contesto</b> |
</div>
<p align="center">

2
locales/ja/README.md generated
View file

@ -69,7 +69,7 @@ Roo Codeは、あなたの働き方に合わせるように適応します。
| | | |
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Codeのインストール</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>プロファイルの設定</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>コードベースのインデックス作成</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>カスタムモード</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>チェックポイント</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>ToDoリスト</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>カスタムモード</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>チェックポイント</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>コンテキスト管理</b> |
</div>
<p align="center">

2
locales/ko/README.md generated
View file

@ -69,7 +69,7 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다.
| | | |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Code 설치하기</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>프로필 구성하기</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>코드베이스 인덱싱</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>사용자 지정 모드</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>체크포인트</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>할 일 목록</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>사용자 지정 모드</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>체크포인트</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>컨텍스트 관리</b> |
</div>
<p align="center">

2
locales/nl/README.md generated
View file

@ -69,7 +69,7 @@ Meer info: [Modi gebruiken](https://docs.roocode.com/basic-usage/using-modes)
| | | |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Code installeren</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Profielen configureren</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Codebase indexeren</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Aangepaste modi</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>To-Do Lijsten</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Aangepaste modi</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Contextbeheer</b> |
</div>
<p align="center">

2
locales/pl/README.md generated
View file

@ -69,7 +69,7 @@ Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-mode
| | | |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Instalacja Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Konfiguracja profili</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indeksowanie bazy kodu</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Tryby niestandardowe</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Punkty kontrolne</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Listy zadań</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Tryby niestandardowe</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Punkty kontrolne</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Zarządzanie Kontekstem</b> |
</div>
<p align="center">

View file

@ -69,7 +69,7 @@ Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [
| | | |
| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Instalando o Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurando perfis</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indexação da base de código</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modos personalizados</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Listas de tarefas</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modos personalizados</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gerenciamento de Contexto</b> |
</div>
<p align="center">

2
locales/ru/README.md generated
View file

@ -69,7 +69,7 @@ Roo Code адаптируется к вашему стилю работы, а н
| | | |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Установка Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Настройка профилей</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Индексация кодовой базы</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Пользовательские режимы</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Контрольные точки</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Списки дел</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Пользовательские режимы</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Контрольные точки</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Управление Контекстом</b> |
</div>
<p align="center">

2
locales/tr/README.md generated
View file

@ -69,7 +69,7 @@ Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes
| | | |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Code Kurulumu</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Profilleri Yapılandırma</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Kod Tabanı İndeksleme</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Özel Modlar</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Kontrol Noktaları</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Yapılacaklar Listeleri</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Özel Modlar</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Kontrol Noktaları</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Bağlam Yönetimi</b> |
</div>
<p align="center">

2
locales/vi/README.md generated
View file

@ -69,7 +69,7 @@ Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-
| | | |
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Cài đặt Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Định cấu hình Hồ sơ</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Lập chỉ mục cơ sở mã</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Chế độ tùy chỉnh</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Điểm kiểm tra</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>Danh sách việc cần làm</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Chế độ tùy chỉnh</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Điểm kiểm tra</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Quản lý Ngữ cảnh</b> |
</div>
<p align="center">

View file

@ -69,7 +69,7 @@ Roo Code 适应您的工作方式,而不是相反:
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>安装 Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>配置个人资料</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>代码库索引</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>自定义模式</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>检查点</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>待办事项列表</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>自定义模式</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>检查点</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>上下文管理</b> |
</div>
<p align="center">

View file

@ -69,7 +69,7 @@ Roo Code 適應您的工作方式,而不是相反:
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>安裝 Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>設定設定檔</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>程式碼庫索引</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>自訂模式</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>檢查點</b> | <a href="https://www.youtube.com/watch?v=6h5vB9PpoPk"><img src="https://img.youtube.com/vi/6h5vB9PpoPk/maxresdefault.jpg" width="100%"></a><br><b>待辦事項清單</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>自訂模式</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>檢查點</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>上下文管理</b> |
</div>
<p align="center">

View file

@ -53,6 +53,9 @@
]
},
"pnpm": {
"onlyBuiltDependencies": [
"@vscode/ripgrep"
],
"overrides": {
"tar-fs": ">=3.1.1",
"esbuild": ">=0.25.0",

View file

@ -158,6 +158,58 @@ export function copyWasms(srcDir: string, distDir: string): void {
})
console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${distDir}`)
// Copy esbuild-wasm files for custom tool transpilation (cross-platform).
copyEsbuildWasmFiles(nodeModulesDir, distDir)
}
/**
* Copy esbuild-wasm files to the dist/bin directory.
*
* This function copies the esbuild-wasm CLI and WASM binary, which provides
* a cross-platform esbuild implementation that works on all platforms.
*
* Files copied:
* - bin/esbuild (Node.js CLI script)
* - esbuild.wasm (WASM binary)
* - wasm_exec_node.js (Go WASM runtime for Node.js)
* - wasm_exec.js (Go WASM runtime dependency)
*/
function copyEsbuildWasmFiles(nodeModulesDir: string, distDir: string): void {
const esbuildWasmDir = path.join(nodeModulesDir, "esbuild-wasm")
if (!fs.existsSync(esbuildWasmDir)) {
throw new Error(`Directory does not exist: ${esbuildWasmDir}`)
}
// Create bin directory in dist.
const binDir = path.join(distDir, "bin")
fs.mkdirSync(binDir, { recursive: true })
// Files to copy - the esbuild CLI script expects wasm_exec_node.js and esbuild.wasm
// to be one directory level up from the bin directory (i.e., in distDir directly).
// wasm_exec_node.js requires wasm_exec.js, so we need to copy that too.
const filesToCopy = [
{ src: path.join(esbuildWasmDir, "bin", "esbuild"), dest: path.join(binDir, "esbuild") },
{ src: path.join(esbuildWasmDir, "esbuild.wasm"), dest: path.join(distDir, "esbuild.wasm") },
{ src: path.join(esbuildWasmDir, "wasm_exec_node.js"), dest: path.join(distDir, "wasm_exec_node.js") },
{ src: path.join(esbuildWasmDir, "wasm_exec.js"), dest: path.join(distDir, "wasm_exec.js") },
]
for (const { src, dest } of filesToCopy) {
fs.copyFileSync(src, dest)
// Make CLI executable.
if (src.endsWith("esbuild")) {
try {
fs.chmodSync(dest, 0o755)
} catch {
// Ignore chmod errors on Windows.
}
}
}
console.log(`[copyWasms] Copied ${filesToCopy.length} esbuild-wasm files to ${distDir}`)
}
export function copyLocales(srcDir: string, distDir: string): void {

View file

@ -331,10 +331,15 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
await this.storeCredentials(credentials)
// Store the provider model if provided
// Store the provider model if provided, or flag that no model was selected
if (providerModel) {
await this.context.globalState.update("roo-provider-model", providerModel)
await this.context.globalState.update("roo-auth-skip-model", undefined)
this.log(`[auth] Stored provider model: ${providerModel}`)
} else {
// No model was selected during signup - flag this for the webview
await this.context.globalState.update("roo-auth-skip-model", true)
this.log(`[auth] No provider model selected during signup`)
}
const vscode = await importVscode()

View file

@ -395,9 +395,38 @@ describe("WebAuthService", () => {
await authService.handleCallback("auth-code", storedState, null, "xai/grok-code-fast-1")
expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-provider-model", "xai/grok-code-fast-1")
expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-auth-skip-model", undefined)
expect(mockLog).toHaveBeenCalledWith("[auth] Stored provider model: xai/grok-code-fast-1")
})
it("should set skip model flag when provider model is NOT provided in callback", async () => {
const storedState = "valid-state"
mockContext.globalState.get.mockReturnValue(storedState)
// Mock successful Clerk sign-in response
const mockResponse = {
ok: true,
json: () =>
Promise.resolve({
response: { created_session_id: "session-123" },
}),
headers: {
get: (header: string) => (header === "authorization" ? "Bearer token-123" : null),
},
}
mockFetch.mockResolvedValue(mockResponse)
const vscode = await import("vscode")
const mockShowInfo = vi.fn()
vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
// Call without provider model
await authService.handleCallback("auth-code", storedState, null)
expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-auth-skip-model", true)
expect(mockLog).toHaveBeenCalledWith("[auth] No provider model selected during signup")
})
it("should handle Clerk API errors", async () => {
const storedState = "valid-state"
mockContext.globalState.get.mockReturnValue(storedState)

View file

@ -0,0 +1,4 @@
import { config } from "@roo-code/config-eslint/base"
/** @type {import("eslint").Linter.Config} */
export default [...config]

View file

@ -0,0 +1,26 @@
{
"name": "@roo-code/core",
"description": "Platform agnostic core functionality for Roo Code.",
"version": "0.0.0",
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"lint": "eslint src --ext=ts --max-warnings=0",
"check-types": "tsc --noEmit",
"test": "vitest run",
"clean": "rimraf .turbo"
},
"dependencies": {
"@roo-code/types": "workspace:^",
"esbuild": "^0.25.0",
"execa": "^9.5.2",
"openai": "^5.12.2",
"zod": "^3.25.61"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "^24.1.0",
"vitest": "^3.2.3"
}
}

View file

@ -0,0 +1,231 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Native Protocol snapshots > should generate correct native definition for cached tool 1`] = `
{
"function": {
"description": "Cached tool",
"name": "cached",
"parameters": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
}
`;
exports[`Native Protocol snapshots > should generate correct native definition for legacy tool (using args) 1`] = `
{
"function": {
"description": "Legacy tool using args",
"name": "legacy",
"parameters": {
"additionalProperties": false,
"properties": {
"input": {
"description": "The input string",
"type": "string",
},
},
"required": [
"input",
],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
}
`;
exports[`Native Protocol snapshots > should generate correct native definition for mixed export tool 1`] = `
{
"function": {
"description": "Valid",
"name": "mixed_validTool",
"parameters": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
}
`;
exports[`Native Protocol snapshots > should generate correct native definition for simple tool 1`] = `
{
"function": {
"description": "Simple tool",
"name": "simple",
"parameters": {
"additionalProperties": false,
"properties": {
"value": {
"description": "The input value",
"type": "string",
},
},
"required": [
"value",
],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
}
`;
exports[`Native Protocol snapshots > should generate correct native definitions for all fixtures combined 1`] = `
[
{
"function": {
"description": "Simple tool",
"name": "simple",
"parameters": {
"additionalProperties": false,
"properties": {
"value": {
"description": "The input value",
"type": "string",
},
},
"required": [
"value",
],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
},
{
"function": {
"description": "Cached tool",
"name": "cached",
"parameters": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
},
{
"function": {
"description": "Legacy tool using args",
"name": "legacy",
"parameters": {
"additionalProperties": false,
"properties": {
"input": {
"description": "The input string",
"type": "string",
},
},
"required": [
"input",
],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
},
{
"function": {
"description": "Tool A",
"name": "multi_toolA",
"parameters": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
},
{
"function": {
"description": "Tool B",
"name": "multi_toolB",
"parameters": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
},
{
"function": {
"description": "Valid",
"name": "mixed_validTool",
"parameters": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
},
]
`;
exports[`Native Protocol snapshots > should generate correct native definitions for multi export tools 1`] = `
[
{
"function": {
"description": "Tool A",
"name": "multi_toolA",
"parameters": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
},
{
"function": {
"description": "Tool B",
"name": "multi_toolB",
"parameters": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object",
},
"source": undefined,
"strict": true,
},
"type": "function",
},
]
`;

View file

@ -0,0 +1,129 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`XML Protocol snapshots > should generate correct XML description for all fixtures combined 1`] = `
"# Custom Tools
The following custom tools are available for this mode. Use them in the same way as built-in tools.
## simple
Description: Simple tool
Parameters:
- value: (required) The input value (type: string)
Usage:
<simple>
<value>value value here</value>
</simple>
## cached
Description: Cached tool
Parameters:
Usage:
<cached>
</cached>
## legacy
Description: Legacy tool using args
Parameters:
- input: (required) The input string (type: string)
Usage:
<legacy>
<input>input value here</input>
</legacy>
## multi_toolA
Description: Tool A
Parameters:
Usage:
<multi_toolA>
</multi_toolA>
## multi_toolB
Description: Tool B
Parameters:
Usage:
<multi_toolB>
</multi_toolB>
## mixed_validTool
Description: Valid
Parameters:
Usage:
<mixed_validTool>
</mixed_validTool>"
`;
exports[`XML Protocol snapshots > should generate correct XML description for cached tool 1`] = `
"# Custom Tools
The following custom tools are available for this mode. Use them in the same way as built-in tools.
## cached
Description: Cached tool
Parameters:
Usage:
<cached>
</cached>"
`;
exports[`XML Protocol snapshots > should generate correct XML description for legacy tool (using args) 1`] = `
"# Custom Tools
The following custom tools are available for this mode. Use them in the same way as built-in tools.
## legacy
Description: Legacy tool using args
Parameters:
- input: (required) The input string (type: string)
Usage:
<legacy>
<input>input value here</input>
</legacy>"
`;
exports[`XML Protocol snapshots > should generate correct XML description for mixed export tool 1`] = `
"# Custom Tools
The following custom tools are available for this mode. Use them in the same way as built-in tools.
## mixed_validTool
Description: Valid
Parameters:
Usage:
<mixed_validTool>
</mixed_validTool>"
`;
exports[`XML Protocol snapshots > should generate correct XML description for multi export tools 1`] = `
"# Custom Tools
The following custom tools are available for this mode. Use them in the same way as built-in tools.
## multi_toolA
Description: Tool A
Parameters:
Usage:
<multi_toolA>
</multi_toolA>
## multi_toolB
Description: Tool B
Parameters:
Usage:
<multi_toolB>
</multi_toolB>"
`;
exports[`XML Protocol snapshots > should generate correct XML description for simple tool 1`] = `
"# Custom Tools
The following custom tools are available for this mode. Use them in the same way as built-in tools.
## simple
Description: Simple tool
Parameters:
- value: (required) The input value (type: string)
Usage:
<simple>
<value>value value here</value>
</simple>"
`;

View file

@ -0,0 +1,146 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Serialization snapshots > should correctly serialize all fixtures 1`] = `
[
{
"description": "Simple tool",
"name": "simple",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"value": {
"description": "The input value",
"type": "string",
},
},
"required": [
"value",
],
"type": "object",
},
"source": undefined,
},
{
"description": "Cached tool",
"name": "cached",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
"type": "object",
},
"source": undefined,
},
{
"description": "Legacy tool using args",
"name": "legacy",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"input": {
"description": "The input string",
"type": "string",
},
},
"required": [
"input",
],
"type": "object",
},
"source": undefined,
},
{
"description": "Tool A",
"name": "multi_toolA",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
"type": "object",
},
"source": undefined,
},
{
"description": "Tool B",
"name": "multi_toolB",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
"type": "object",
},
"source": undefined,
},
{
"description": "Valid",
"name": "mixed_validTool",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
"type": "object",
},
"source": undefined,
},
]
`;
exports[`Serialization snapshots > should correctly serialize cached tool 1`] = `
{
"description": "Cached tool",
"name": "cached",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
"type": "object",
},
"source": undefined,
}
`;
exports[`Serialization snapshots > should correctly serialize legacy tool (using args) 1`] = `
{
"description": "Legacy tool using args",
"name": "legacy",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"input": {
"description": "The input string",
"type": "string",
},
},
"required": [
"input",
],
"type": "object",
},
"source": undefined,
}
`;
exports[`Serialization snapshots > should correctly serialize simple tool 1`] = `
{
"description": "Simple tool",
"name": "simple",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"value": {
"description": "The input value",
"type": "string",
},
},
"required": [
"value",
],
"type": "object",
},
"source": undefined,
}
`;

View file

@ -0,0 +1,381 @@
// pnpm --filter @roo-code/core test src/custom-tools/__tests__/custom-tool-registry.spec.ts
import path from "path"
import { fileURLToPath } from "url"
import { type CustomToolDefinition, parametersSchema as z } from "@roo-code/types"
import { CustomToolRegistry } from "../custom-tool-registry.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TEST_FIXTURES_DIR = path.join(__dirname, "fixtures")
const TEST_FIXTURES_OVERRIDE_DIR = path.join(__dirname, "fixtures-override")
describe("CustomToolRegistry", () => {
let registry: CustomToolRegistry
beforeEach(() => {
registry = new CustomToolRegistry()
})
describe("validation", () => {
it("should accept a valid tool definition", () => {
const validTool = {
name: "valid_tool",
description: "A valid tool",
parameters: z.object({ name: z.string() }),
execute: async () => "result",
}
expect(() => registry.register(validTool)).not.toThrow()
expect(registry.has("valid_tool")).toBe(true)
})
it("should reject empty description", () => {
const invalidTool = {
name: "invalid_tool",
description: "",
parameters: z.object({}),
execute: async () => "result",
}
expect(() => registry.register(invalidTool as CustomToolDefinition)).toThrow(/Invalid tool definition/)
})
it("should reject non-Zod parameters", () => {
const invalidTool = {
name: "bad_params_tool",
description: "Tool with bad params",
parameters: { foo: "bar" },
execute: async () => "result",
}
expect(() => registry.register(invalidTool as unknown as CustomToolDefinition)).toThrow(
/Invalid tool definition/,
)
})
it("should allow missing parameters", () => {
const toolWithoutParams = {
name: "no_params_tool",
description: "Tool without parameters",
execute: async () => "result",
}
expect(() => registry.register(toolWithoutParams)).not.toThrow()
expect(registry.has("no_params_tool")).toBe(true)
})
it("should reject empty name", () => {
const invalidTool = {
name: "",
description: "Tool with empty name",
execute: async () => "result",
}
expect(() => registry.register(invalidTool as CustomToolDefinition)).toThrow(/Invalid tool definition/)
})
it("should reject missing name", () => {
const invalidTool = {
description: "Tool without name",
execute: async () => "result",
}
expect(() => registry.register(invalidTool as unknown as CustomToolDefinition)).toThrow(
/Invalid tool definition/,
)
})
})
describe("register", () => {
it("should register a valid tool", () => {
const tool: CustomToolDefinition = {
name: "test_tool",
description: "Test tool",
parameters: z.object({ input: z.string() }),
execute: async (args: { input: string }) => `Processed: ${args.input}`,
}
registry.register(tool)
expect(registry.has("test_tool")).toBe(true)
expect(registry.size).toBe(1)
})
it("should throw for invalid tool definition", () => {
const invalidTool = {
name: "bad_tool",
description: "",
execute: async () => "result",
}
expect(() => registry.register(invalidTool as CustomToolDefinition)).toThrow(/Invalid tool definition/)
})
it("should overwrite existing tool with same id", () => {
const tool1: CustomToolDefinition = {
name: "tool",
description: "First version",
execute: async () => "v1",
}
const tool2: CustomToolDefinition = {
name: "tool",
description: "Second version",
execute: async () => "v2",
}
registry.register(tool1)
registry.register(tool2)
expect(registry.size).toBe(1)
expect(registry.get("tool")?.description).toBe("Second version")
})
})
describe("unregister", () => {
it("should remove a registered tool", () => {
registry.register({
name: "tool",
description: "Test",
execute: async () => "result",
})
const result = registry.unregister("tool")
expect(result).toBe(true)
expect(registry.has("tool")).toBe(false)
})
it("should return false for non-existent tool", () => {
const result = registry.unregister("nonexistent")
expect(result).toBe(false)
})
})
describe("get", () => {
it("should return registered tool", () => {
registry.register({
name: "my_tool",
description: "My tool",
execute: async () => "result",
})
const tool = registry.get("my_tool")
expect(tool).toBeDefined()
expect(tool?.name).toBe("my_tool")
expect(tool?.description).toBe("My tool")
})
it("should return undefined for non-existent tool", () => {
expect(registry.get("nonexistent")).toBeUndefined()
})
})
describe("list", () => {
it("should return all tool IDs", () => {
registry.register({ name: "tool_a", description: "A", execute: async () => "a" })
registry.register({ name: "tool_b", description: "B", execute: async () => "b" })
registry.register({ name: "tool_c", description: "C", execute: async () => "c" })
const ids = registry.list()
expect(ids).toHaveLength(3)
expect(ids).toContain("tool_a")
expect(ids).toContain("tool_b")
expect(ids).toContain("tool_c")
})
it("should return empty array when no tools registered", () => {
expect(registry.list()).toEqual([])
})
})
describe("getAll", () => {
it("should return all tools as array", () => {
registry.register({ name: "tool1", description: "Tool 1", execute: async () => "1" })
registry.register({ name: "tool2", description: "Tool 2", execute: async () => "2" })
const all = registry.getAll()
expect(all).toHaveLength(2)
expect(all.find((t) => t.name === "tool1")?.description).toBe("Tool 1")
expect(all.find((t) => t.name === "tool2")?.description).toBe("Tool 2")
})
})
describe("clear", () => {
it("should remove all registered tools", () => {
registry.register({ name: "tool1", description: "1", execute: async () => "1" })
registry.register({ name: "tool2", description: "2", execute: async () => "2" })
expect(registry.size).toBe(2)
registry.clear()
expect(registry.size).toBe(0)
expect(registry.list()).toEqual([])
})
})
describe.sequential("loadFromDirectory", () => {
it("should load tools from TypeScript files", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
expect(result.loaded).toContain("simple")
expect(registry.has("simple")).toBe(true)
}, 60000)
it("should handle named exports", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
expect(result.loaded).toContain("multi_toolA")
expect(result.loaded).toContain("multi_toolB")
}, 30000)
it("should report validation failures", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
const invalidFailure = result.failed.find((f) => f.file === "invalid.ts")
expect(invalidFailure).toBeDefined()
expect(invalidFailure?.error).toContain("Invalid tool definition")
}, 30000)
it("should return empty results for non-existent directory", async () => {
const result = await registry.loadFromDirectory("/nonexistent/path")
expect(result.loaded).toHaveLength(0)
expect(result.failed).toHaveLength(0)
})
it("should skip non-tool exports silently", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
expect(result.loaded).toContain("mixed_validTool")
// The non-tool exports should not appear in loaded or failed.
expect(result.loaded).not.toContain("mixed_someString")
expect(result.loaded).not.toContain("mixed_someNumber")
expect(result.loaded).not.toContain("mixed_someObject")
}, 30000)
it("should support args as alias for parameters", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
expect(result.loaded).toContain("legacy")
const tool = registry.get("legacy")
expect(tool?.parameters).toBeDefined()
}, 30000)
})
describe.sequential("clearCache", () => {
it("should clear the TypeScript compilation cache", async () => {
await registry.loadFromDirectory(TEST_FIXTURES_DIR)
registry.clearCache()
// Should be able to load again without issues.
registry.clear()
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
expect(result.loaded).toContain("cached")
}, 30000)
})
describe.sequential("loadFromDirectories", () => {
it("should load tools from multiple directories", async () => {
const result = await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
// Should load tools from both directories.
expect(result.loaded).toContain("simple") // From both directories (override wins).
expect(result.loaded).toContain("unique_override") // Only in override directory.
expect(result.loaded).toContain("multi_toolA") // Only in fixtures directory.
}, 60000)
it("should allow later directories to override earlier ones", async () => {
await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
// The simple tool should have the overridden description.
const simpleTool = registry.get("simple")
expect(simpleTool).toBeDefined()
expect(simpleTool?.description).toBe("Simple tool - OVERRIDDEN")
}, 60000)
it("should preserve order: first directory loaded first, second overrides", async () => {
// Load in reverse order: override first, then fixtures.
await registry.loadFromDirectories([TEST_FIXTURES_OVERRIDE_DIR, TEST_FIXTURES_DIR])
// Now the original fixtures directory should win.
const simpleTool = registry.get("simple")
expect(simpleTool).toBeDefined()
expect(simpleTool?.description).toBe("Simple tool") // Original wins when loaded second.
}, 60000)
it("should handle non-existent directories in the array", async () => {
const result = await registry.loadFromDirectories([
"/nonexistent/path",
TEST_FIXTURES_DIR,
"/another/nonexistent",
])
// Should still load from the existing directory.
expect(result.loaded).toContain("simple")
expect(result.failed).toHaveLength(1) // Only the invalid.ts from fixtures.
}, 60000)
it("should handle empty array", async () => {
const result = await registry.loadFromDirectories([])
expect(result.loaded).toHaveLength(0)
expect(result.failed).toHaveLength(0)
})
it("should combine results from all directories", async () => {
const result = await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
// Loaded should include tools from both (with duplicates since simple is loaded twice).
// The "simple" tool is loaded from both directories.
const simpleCount = result.loaded.filter((name) => name === "simple").length
expect(simpleCount).toBe(2) // Listed twice in loaded results.
}, 60000)
})
describe.sequential("loadFromDirectoriesIfStale", () => {
it("should load tools from multiple directories when stale", async () => {
const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
expect(result.loaded).toContain("simple")
expect(result.loaded).toContain("unique_override")
}, 60000)
it("should not reload if directories are not stale", async () => {
// First load.
await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR])
// Clear tools but keep staleness tracking.
// (firstLoadSize is captured to document that tools were loaded, then cleared).
const _firstLoadSize = registry.size
registry.clear()
// Second load - should return cached tool names without reloading.
const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR])
// Registry was cleared, not stale so no reload.
expect(result.loaded).toEqual([])
}, 30000)
it("should handle mixed stale and non-stale directories", async () => {
// Load from fixtures first.
await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR])
// Load from both - fixtures is not stale, override is new (stale).
const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
// Override directory tools should be loaded (it's stale/new).
expect(result.loaded).toContain("simple") // From override (stale).
expect(result.loaded).toContain("unique_override") // From override (stale).
}, 60000)
})
})

View file

@ -0,0 +1,253 @@
import fs from "fs"
import os from "os"
import path from "path"
import { getEsbuildScriptPath, runEsbuild, NODE_BUILTIN_MODULES, COMMONJS_REQUIRE_BANNER } from "../esbuild-runner.js"
describe("getEsbuildScriptPath", () => {
it("should find esbuild-wasm script in node_modules in development", () => {
const scriptPath = getEsbuildScriptPath()
// Should find the script.
expect(typeof scriptPath).toBe("string")
expect(scriptPath.length).toBeGreaterThan(0)
// The script should exist.
expect(fs.existsSync(scriptPath)).toBe(true)
// Should be the esbuild script (not a binary).
expect(scriptPath).toMatch(/esbuild$/)
})
it("should prefer production path when extensionPath is provided and script exists", () => {
// Create a temporary directory with a fake script.
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-"))
const binDir = path.join(tempDir, "dist", "bin")
fs.mkdirSync(binDir, { recursive: true })
const fakeScriptPath = path.join(binDir, "esbuild")
fs.writeFileSync(fakeScriptPath, "#!/usr/bin/env node\nconsole.log('fake esbuild')")
try {
const result = getEsbuildScriptPath(tempDir)
expect(result).toBe(fakeScriptPath)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
it("should fall back to node_modules when production script does not exist", () => {
// Pass a non-existent extension path.
const result = getEsbuildScriptPath("/nonexistent/extension/path")
// Should fall back to development path.
expect(typeof result).toBe("string")
expect(result.length).toBeGreaterThan(0)
expect(fs.existsSync(result)).toBe(true)
})
})
describe("runEsbuild", () => {
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-"))
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
it("should compile a TypeScript file to ESM", async () => {
// Create a simple TypeScript file.
const inputFile = path.join(tempDir, "input.ts")
const outputFile = path.join(tempDir, "output.mjs")
fs.writeFileSync(
inputFile,
`
export const greeting = "Hello, World!"
export function add(a: number, b: number): number {
return a + b
}
`,
)
await runEsbuild({
entryPoint: inputFile,
outfile: outputFile,
format: "esm",
platform: "node",
target: "node18",
bundle: true,
})
// Verify output file exists.
expect(fs.existsSync(outputFile)).toBe(true)
// Verify output content is valid JavaScript.
const outputContent = fs.readFileSync(outputFile, "utf-8")
expect(outputContent).toContain("Hello, World!")
expect(outputContent).toContain("add")
}, 30000)
it("should generate inline source maps when specified", async () => {
const inputFile = path.join(tempDir, "input.ts")
const outputFile = path.join(tempDir, "output.mjs")
fs.writeFileSync(inputFile, `export const value = 42`)
await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", sourcemap: "inline" })
const outputContent = fs.readFileSync(outputFile, "utf-8")
expect(outputContent).toContain("sourceMappingURL=data:")
}, 30000)
it("should throw an error for invalid TypeScript", async () => {
const inputFile = path.join(tempDir, "invalid.ts")
const outputFile = path.join(tempDir, "output.mjs")
// Write syntactically invalid TypeScript.
fs.writeFileSync(inputFile, `export const value = {{{ invalid syntax`)
await expect(runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm" })).rejects.toThrow()
}, 30000)
it("should throw an error for non-existent file", async () => {
const nonExistentFile = path.join(tempDir, "does-not-exist.ts")
const outputFile = path.join(tempDir, "output.mjs")
await expect(runEsbuild({ entryPoint: nonExistentFile, outfile: outputFile, format: "esm" })).rejects.toThrow()
}, 30000)
it("should bundle dependencies when bundle option is true", async () => {
// Create two files where one imports the other.
const libFile = path.join(tempDir, "lib.ts")
const mainFile = path.join(tempDir, "main.ts")
const outputFile = path.join(tempDir, "output.mjs")
fs.writeFileSync(libFile, `export const PI = 3.14159`)
fs.writeFileSync(
mainFile,
`
import { PI } from "./lib.js"
export const circumference = (r: number) => 2 * PI * r
`,
)
await runEsbuild({ entryPoint: mainFile, outfile: outputFile, format: "esm", bundle: true })
const outputContent = fs.readFileSync(outputFile, "utf-8")
// The PI constant should be bundled inline.
expect(outputContent).toContain("3.14159")
}, 30000)
it("should respect platform option", async () => {
const inputFile = path.join(tempDir, "input.ts")
const outputFile = path.join(tempDir, "output.mjs")
fs.writeFileSync(inputFile, `export const value = process.env.NODE_ENV`)
await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", platform: "node" })
// File should be created successfully.
expect(fs.existsSync(outputFile)).toBe(true)
}, 30000)
it("should keep external modules as imports instead of bundling", async () => {
const inputFile = path.join(tempDir, "input.ts")
const outputFile = path.join(tempDir, "output.mjs")
// Write code that imports fs (a Node.js built-in).
fs.writeFileSync(
inputFile,
`
import fs from "fs"
export function fileExists(p: string): boolean {
return fs.existsSync(p)
}
`,
)
await runEsbuild({
entryPoint: inputFile,
outfile: outputFile,
format: "esm",
bundle: true,
external: ["fs"],
})
const outputContent = fs.readFileSync(outputFile, "utf-8")
// fs should remain as an import, not bundled.
expect(outputContent).toMatch(/import.*from\s*["']fs["']/)
}, 30000)
it("should add banner code when specified", async () => {
const inputFile = path.join(tempDir, "input.ts")
const outputFile = path.join(tempDir, "output.mjs")
fs.writeFileSync(inputFile, `export const greeting = "Hello"`)
const customBanner = "// This is a custom banner comment"
await runEsbuild({
entryPoint: inputFile,
outfile: outputFile,
format: "esm",
banner: customBanner,
})
const outputContent = fs.readFileSync(outputFile, "utf-8")
// Banner should be at the start of the file.
expect(outputContent.startsWith(customBanner)).toBe(true)
}, 30000)
it("should add CommonJS require shim banner for ESM bundles", async () => {
const inputFile = path.join(tempDir, "input.ts")
const outputFile = path.join(tempDir, "output.mjs")
fs.writeFileSync(inputFile, `export const value = 42`)
await runEsbuild({
entryPoint: inputFile,
outfile: outputFile,
format: "esm",
banner: COMMONJS_REQUIRE_BANNER,
})
const outputContent = fs.readFileSync(outputFile, "utf-8")
// Should contain the createRequire shim.
expect(outputContent).toContain("createRequire")
expect(outputContent).toContain("import.meta.url")
}, 30000)
})
describe("NODE_BUILTIN_MODULES", () => {
it("should include common Node.js built-in modules", () => {
expect(NODE_BUILTIN_MODULES).toContain("fs")
expect(NODE_BUILTIN_MODULES).toContain("path")
expect(NODE_BUILTIN_MODULES).toContain("crypto")
expect(NODE_BUILTIN_MODULES).toContain("http")
expect(NODE_BUILTIN_MODULES).toContain("https")
expect(NODE_BUILTIN_MODULES).toContain("os")
expect(NODE_BUILTIN_MODULES).toContain("child_process")
expect(NODE_BUILTIN_MODULES).toContain("stream")
expect(NODE_BUILTIN_MODULES).toContain("util")
expect(NODE_BUILTIN_MODULES).toContain("events")
})
it("should be an array of strings", () => {
expect(Array.isArray(NODE_BUILTIN_MODULES)).toBe(true)
expect(NODE_BUILTIN_MODULES.every((m) => typeof m === "string")).toBe(true)
})
})
describe("COMMONJS_REQUIRE_BANNER", () => {
it("should provide createRequire shim", () => {
expect(COMMONJS_REQUIRE_BANNER).toContain("createRequire")
expect(COMMONJS_REQUIRE_BANNER).toContain("import.meta.url")
})
it("should define require variable", () => {
expect(COMMONJS_REQUIRE_BANNER).toMatch(/var require\s*=/)
})
})

View file

@ -0,0 +1,11 @@
import { parametersSchema, defineCustomTool } from "@roo-code/types"
// This tool has the same name as the one in fixtures/ to test override behavior.
export default defineCustomTool({
name: "simple",
description: "Simple tool - OVERRIDDEN",
parameters: parametersSchema.object({ value: parametersSchema.string().describe("The input value") }),
async execute(args: { value: string }) {
return "Overridden Result: " + args.value
},
})

View file

@ -0,0 +1,11 @@
import { parametersSchema, defineCustomTool } from "@roo-code/types"
// This tool only exists in fixtures-override/ to test combined loading.
export default defineCustomTool({
name: "unique_override",
description: "A unique tool only in override directory",
parameters: parametersSchema.object({ input: parametersSchema.string().describe("The input") }),
async execute(args: { input: string }) {
return "Unique: " + args.input
},
})

View file

@ -0,0 +1,10 @@
import { parametersSchema, defineCustomTool } from "@roo-code/types"
export default defineCustomTool({
name: "cached",
description: "Cached tool",
parameters: parametersSchema.object({}),
async execute() {
return "cached"
},
})

View file

@ -0,0 +1,4 @@
export default {
description: "", // Invalid: empty description.
execute: async () => "result",
}

View file

@ -0,0 +1,10 @@
import { parametersSchema, defineCustomTool } from "@roo-code/types"
export default defineCustomTool({
name: "legacy",
description: "Legacy tool using args",
parameters: parametersSchema.object({ input: parametersSchema.string().describe("The input string") }),
async execute(args: { input: string }) {
return args.input
},
})

View file

@ -0,0 +1,16 @@
import { parametersSchema, defineCustomTool } from "@roo-code/types"
// This is a valid tool.
export const validTool = defineCustomTool({
name: "mixed_validTool",
description: "Valid",
parameters: parametersSchema.object({}),
async execute() {
return "valid"
},
})
// These should be silently skipped.
export const someString = "not a tool"
export const someNumber = 42
export const someObject = { foo: "bar" }

View file

@ -0,0 +1,19 @@
import { parametersSchema, defineCustomTool } from "@roo-code/types"
export const toolA = defineCustomTool({
name: "multi_toolA",
description: "Tool A",
parameters: parametersSchema.object({}),
async execute() {
return "A"
},
})
export const toolB = defineCustomTool({
name: "multi_toolB",
description: "Tool B",
parameters: parametersSchema.object({}),
async execute() {
return "B"
},
})

View file

@ -0,0 +1,10 @@
import { parametersSchema, defineCustomTool } from "@roo-code/types"
export default defineCustomTool({
name: "simple",
description: "Simple tool",
parameters: parametersSchema.object({ value: parametersSchema.string().describe("The input value") }),
async execute(args: { value: string }) {
return "Result: " + args.value
},
})

View file

@ -0,0 +1,250 @@
// pnpm --filter @roo-code/core test src/custom-tools/__tests__/format-native.spec.ts
import { type SerializedCustomToolDefinition, parametersSchema as z, defineCustomTool } from "@roo-code/types"
import { serializeCustomTool, serializeCustomTools } from "../serialize.js"
import { formatNative } from "../format-native.js"
import simpleTool from "./fixtures/simple.js"
import cachedTool from "./fixtures/cached.js"
import legacyTool from "./fixtures/legacy.js"
import { toolA, toolB } from "./fixtures/multi.js"
import { validTool as mixedValidTool } from "./fixtures/mixed.js"
const fixtureTools = {
simple: simpleTool,
cached: cachedTool,
legacy: legacyTool,
multi_toolA: toolA,
multi_toolB: toolB,
mixed_validTool: mixedValidTool,
}
describe("formatNative", () => {
it("should convert a tool without args", () => {
const tool = defineCustomTool({
name: "simple_tool",
description: "A simple tool",
async execute() {
return "done"
},
})
const serialized = serializeCustomTool(tool)
const result = formatNative(serialized)
expect(result).toEqual({
type: "function",
function: {
name: "simple_tool",
description: "A simple tool",
parameters: undefined,
strict: true,
},
})
})
it("should convert a tool with required args", () => {
const tool = defineCustomTool({
name: "greeter",
description: "Greets a person",
parameters: z.object({
name: z.string().describe("Person's name"),
}),
async execute({ name }) {
return `Hello, ${name}!`
},
})
const serialized = serializeCustomTool(tool)
const result = formatNative(serialized)
expect(result.type).toBe("function")
expect(result.function.name).toBe("greeter")
expect(result.function.description).toBe("Greets a person")
expect(result.function.parameters?.properties).toEqual({
name: {
type: "string",
description: "Person's name",
},
})
expect(result.function.parameters?.required).toEqual(["name"])
expect(result.function.parameters?.additionalProperties).toBe(false)
})
it("should convert a tool with optional args", () => {
const tool = defineCustomTool({
name: "optional_tool",
description: "Tool with optional args",
parameters: z.object({
format: z.string().optional().describe("Output format"),
}),
async execute() {
return "done"
},
})
const serialized = serializeCustomTool(tool)
const result = formatNative(serialized)
expect(result.function.parameters?.required).toEqual([])
expect(result.function.parameters?.properties).toEqual({
format: {
type: "string",
description: "Output format",
},
})
})
it("should convert a tool with mixed required and optional args", () => {
const tool = defineCustomTool({
name: "mixed_tool",
description: "Tool with mixed args",
parameters: z.object({
input: z.string().describe("Required input"),
options: z.object({}).optional().describe("Optional config"),
count: z.number().describe("Also required"),
}),
async execute() {
return "done"
},
})
const serialized = serializeCustomTool(tool)
const result = formatNative(serialized)
expect(result.function.parameters?.required).toEqual(["input", "count"])
expect(result.function.parameters?.properties).toEqual({
input: {
type: "string",
description: "Required input",
},
options: {
additionalProperties: false,
properties: {},
type: "object",
description: "Optional config",
},
count: {
type: "number",
description: "Also required",
},
})
})
it("should map type strings to JSON Schema types", () => {
const tool = defineCustomTool({
name: "typed_tool",
description: "Tool with various types",
parameters: z.object({
str: z.string().describe("A string"),
num: z.number().describe("A number"),
bool: z.boolean().describe("A boolean"),
obj: z.object({}).describe("An object"),
arr: z.array(z.string()).describe("An array"),
}),
async execute() {
return "done"
},
})
const serialized = serializeCustomTool(tool)
const result = formatNative(serialized)
const props = result.function.parameters?.properties as
| Record<string, { type: string; description?: string }>
| undefined
expect(props?.str?.type).toBe("string")
expect(props?.num?.type).toBe("number")
expect(props?.bool?.type).toBe("boolean")
expect(props?.obj?.type).toBe("object")
expect(props?.arr?.type).toBe("array")
})
it("should pass through raw parameters as-is", () => {
// formatNative is a simple wrapper that passes through parameters unchanged
const serialized = {
name: "test_tool",
description: "Tool with specific type",
parameters: {
type: "object",
properties: {
data: { type: "integer", description: "Integer type" },
},
},
} as SerializedCustomToolDefinition
const result = formatNative(serialized)
expect(result.type).toBe("function")
expect(result.function.name).toBe("test_tool")
const props = result.function.parameters?.properties as Record<string, { type: string }> | undefined
expect(props?.data?.type).toBe("integer")
})
it("should convert multiple tools", () => {
const tools = [
defineCustomTool({
name: "tool_a",
description: "First tool",
async execute() {
return "a"
},
}),
defineCustomTool({
name: "tool_b",
description: "Second tool",
async execute() {
return "b"
},
}),
]
const serialized = serializeCustomTools(tools)
const result = serialized.map(formatNative)
expect(result).toHaveLength(2)
expect(result[0]?.function.name).toBe("tool_a")
expect(result[1]?.function.name).toBe("tool_b")
expect(result.every((t) => t.type === "function")).toBe(true)
})
})
describe("Native Protocol snapshots", () => {
it("should generate correct native definition for simple tool", () => {
const serialized = serializeCustomTool(fixtureTools.simple)
const result = formatNative(serialized)
expect(result).toMatchSnapshot()
})
it("should generate correct native definition for cached tool", () => {
const serialized = serializeCustomTool(fixtureTools.cached)
const result = formatNative(serialized)
expect(result).toMatchSnapshot()
})
it("should generate correct native definition for legacy tool (using args)", () => {
const serialized = serializeCustomTool(fixtureTools.legacy)
const result = formatNative(serialized)
expect(result).toMatchSnapshot()
})
it("should generate correct native definitions for multi export tools", () => {
const serializedA = serializeCustomTool(fixtureTools.multi_toolA)
const serializedB = serializeCustomTool(fixtureTools.multi_toolB)
const result = [serializedA, serializedB].map(formatNative)
expect(result).toMatchSnapshot()
})
it("should generate correct native definition for mixed export tool", () => {
const serialized = serializeCustomTool(fixtureTools.mixed_validTool)
const result = formatNative(serialized)
expect(result).toMatchSnapshot()
})
it("should generate correct native definitions for all fixtures combined", () => {
const allSerialized = Object.values(fixtureTools).map(serializeCustomTool)
const result = allSerialized.map(formatNative)
expect(result).toMatchSnapshot()
})
})

View file

@ -0,0 +1,192 @@
// pnpm --filter @roo-code/core test src/custom-tools/__tests__/format-xml.spec.ts
import { type SerializedCustomToolDefinition, parametersSchema as z, defineCustomTool } from "@roo-code/types"
import { serializeCustomTool, serializeCustomTools } from "../serialize.js"
import { formatXml } from "../format-xml.js"
import simpleTool from "./fixtures/simple.js"
import cachedTool from "./fixtures/cached.js"
import legacyTool from "./fixtures/legacy.js"
import { toolA, toolB } from "./fixtures/multi.js"
import { validTool as mixedValidTool } from "./fixtures/mixed.js"
const fixtureTools = {
simple: simpleTool,
cached: cachedTool,
legacy: legacyTool,
multi_toolA: toolA,
multi_toolB: toolB,
mixed_validTool: mixedValidTool,
}
describe("formatXml", () => {
it("should return empty string for empty tools array", () => {
expect(formatXml([])).toBe("")
})
it("should throw for undefined tools", () => {
expect(() => formatXml(undefined as unknown as SerializedCustomToolDefinition[])).toThrow()
})
it("should generate description for a single tool without args", () => {
const tool = defineCustomTool({
name: "my_tool",
description: "A simple tool that does something",
async execute() {
return "done"
},
})
const serialized = serializeCustomTool(tool)
const result = formatXml([serialized])
expect(result).toContain("# Custom Tools")
expect(result).toContain("## my_tool")
expect(result).toContain("Description: A simple tool that does something")
expect(result).toContain("Parameters: None")
expect(result).toContain("<my_tool>")
expect(result).toContain("</my_tool>")
})
it("should generate description for a tool with required args", () => {
const tool = defineCustomTool({
name: "greeter",
description: "Greets a person by name",
parameters: z.object({
name: z.string().describe("The name of the person to greet"),
}),
async execute({ name }) {
return `Hello, ${name}!`
},
})
const serialized = serializeCustomTool(tool)
const result = formatXml([serialized])
expect(result).toContain("## greeter")
expect(result).toContain("Description: Greets a person by name")
expect(result).toContain("Parameters:")
expect(result).toContain("- name: (required) The name of the person to greet (type: string)")
expect(result).toContain("<greeter>")
expect(result).toContain("<name>name value here</name>")
expect(result).toContain("</greeter>")
})
it("should generate description for a tool with optional args", () => {
const tool = defineCustomTool({
name: "configurable_tool",
description: "A tool with optional configuration",
parameters: z.object({
input: z.string().describe("The input to process"),
format: z.string().optional().describe("Output format"),
}),
async execute({ input, format }) {
return format ? `${input} (${format})` : input
},
})
const serialized = serializeCustomTool(tool)
const result = formatXml([serialized])
expect(result).toContain("- input: (required) The input to process (type: string)")
expect(result).toContain("- format: (optional) Output format (type: string)")
expect(result).toContain("<input>input value here</input>")
expect(result).toContain("<format>optional format value</format>")
})
it("should generate descriptions for multiple tools", () => {
const tools = [
defineCustomTool({
name: "tool_a",
description: "First tool",
async execute() {
return "a"
},
}),
defineCustomTool({
name: "tool_b",
description: "Second tool",
parameters: z.object({
value: z.number().describe("A numeric value"),
}),
async execute() {
return "b"
},
}),
]
const serialized = serializeCustomTools(tools)
const result = formatXml(serialized)
expect(result).toContain("## tool_a")
expect(result).toContain("Description: First tool")
expect(result).toContain("## tool_b")
expect(result).toContain("Description: Second tool")
expect(result).toContain("- value: (required) A numeric value (type: number)")
})
it("should treat args in required array as required", () => {
// Using a raw SerializedToolDefinition to test the required behavior.
const tools: SerializedCustomToolDefinition[] = [
{
name: "test_tool",
description: "Test tool",
parameters: {
type: "object",
properties: {
data: {
type: "object",
description: "Some data",
},
},
required: ["data"],
},
},
]
const result = formatXml(tools)
expect(result).toContain("- data: (required) Some data (type: object)")
expect(result).toContain("<data>data value here</data>")
})
})
describe("XML Protocol snapshots", () => {
it("should generate correct XML description for simple tool", () => {
const serialized = serializeCustomTool(fixtureTools.simple)
const result = formatXml([serialized])
expect(result).toMatchSnapshot()
})
it("should generate correct XML description for cached tool", () => {
const serialized = serializeCustomTool(fixtureTools.cached)
const result = formatXml([serialized])
expect(result).toMatchSnapshot()
})
it("should generate correct XML description for legacy tool (using args)", () => {
const serialized = serializeCustomTool(fixtureTools.legacy)
const result = formatXml([serialized])
expect(result).toMatchSnapshot()
})
it("should generate correct XML description for multi export tools", () => {
const serializedA = serializeCustomTool(fixtureTools.multi_toolA)
const serializedB = serializeCustomTool(fixtureTools.multi_toolB)
const result = formatXml([serializedA, serializedB])
expect(result).toMatchSnapshot()
})
it("should generate correct XML description for mixed export tool", () => {
const serialized = serializeCustomTool(fixtureTools.mixed_validTool)
const result = formatXml([serialized])
expect(result).toMatchSnapshot()
})
it("should generate correct XML description for all fixtures combined", () => {
const allSerialized = Object.values(fixtureTools).map(serializeCustomTool)
const result = formatXml(allSerialized)
expect(result).toMatchSnapshot()
})
})

View file

@ -0,0 +1,225 @@
// pnpm --filter @roo-code/core test src/custom-tools/__tests__/serialize.spec.ts
import { parametersSchema as z, defineCustomTool } from "@roo-code/types"
import { serializeCustomTool, serializeCustomTools } from "../serialize.js"
import simpleTool from "./fixtures/simple.js"
import cachedTool from "./fixtures/cached.js"
import legacyTool from "./fixtures/legacy.js"
import { toolA, toolB } from "./fixtures/multi.js"
import { validTool as mixedValidTool } from "./fixtures/mixed.js"
const fixtureTools = {
simple: simpleTool,
cached: cachedTool,
legacy: legacyTool,
multi_toolA: toolA,
multi_toolB: toolB,
mixed_validTool: mixedValidTool,
}
describe("serializeCustomTool", () => {
it("should serialize a tool without parameters", () => {
const tool = defineCustomTool({
name: "simple_tool",
description: "A simple tool that does something",
async execute() {
return "done"
},
})
const result = serializeCustomTool(tool)
expect(result).toEqual({
name: "simple_tool",
description: "A simple tool that does something",
})
})
it("should serialize a tool with required string parameter", () => {
const tool = defineCustomTool({
name: "greeter",
description: "Greets a person by name",
parameters: z.object({
name: z.string().describe("The name of the person to greet"),
}),
async execute({ name }) {
return `Hello, ${name}!`
},
})
const result = serializeCustomTool(tool)
expect(result.name).toBe("greeter")
expect(result.description).toBe("Greets a person by name")
expect(result.parameters?.properties?.name).toEqual({
type: "string",
description: "The name of the person to greet",
})
expect(result.parameters?.required).toEqual(["name"])
})
it("should serialize a tool with optional parameter", () => {
const tool = defineCustomTool({
name: "configurable_tool",
description: "A tool with optional configuration",
parameters: z.object({
input: z.string().describe("The input to process"),
format: z.string().optional().describe("Output format"),
}),
async execute({ input, format }) {
return format ? `${input} (${format})` : input
},
})
const result = serializeCustomTool(tool)
expect(result.parameters?.properties?.input).toEqual({
type: "string",
description: "The input to process",
})
expect(result.parameters?.properties?.format).toEqual({
type: "string",
description: "Output format",
})
// Only required params should be in the required array
expect(result.parameters?.required).toEqual(["input"])
})
it("should serialize a tool with various types", () => {
const tool = defineCustomTool({
name: "typed_tool",
description: "Tool with various types",
parameters: z.object({
str: z.string().describe("A string"),
num: z.number().describe("A number"),
bool: z.boolean().describe("A boolean"),
obj: z.object({}).describe("An object"),
arr: z.array(z.string()).describe("An array"),
}),
async execute() {
return "done"
},
})
const result = serializeCustomTool(tool)
expect(result.parameters?.properties?.str).toEqual({
description: "A string",
type: "string",
})
expect(result.parameters?.properties?.num).toEqual({
description: "A number",
type: "number",
})
expect(result.parameters?.properties?.bool).toEqual({
description: "A boolean",
type: "boolean",
})
expect(result.parameters?.properties?.obj).toEqual({
additionalProperties: false,
description: "An object",
properties: {},
type: "object",
})
expect(result.parameters?.properties?.arr).toEqual({
description: "An array",
items: { type: "string" },
type: "array",
})
})
it("should handle nullable parameters as optional", () => {
const tool = defineCustomTool({
name: "nullable_tool",
description: "Tool with nullable param",
parameters: z.object({
value: z.string().nullable().describe("A nullable value"),
}),
async execute() {
return "done"
},
})
const result = serializeCustomTool(tool)
expect(result.parameters?.required).toEqual(["value"])
})
it("should handle default values as optional", () => {
const tool = defineCustomTool({
name: "default_tool",
description: "Tool with default param",
parameters: z.object({
count: z.number().default(10).describe("A count with default"),
}),
async execute() {
return "done"
},
})
const result = serializeCustomTool(tool)
expect(result.parameters?.required).toEqual(["count"])
})
})
describe("serializeCustomTools", () => {
it("should return empty array for empty tools array", () => {
expect(serializeCustomTools([])).toEqual([])
})
it("should serialize multiple tools", () => {
const tools = [
defineCustomTool({
name: "tool_a",
description: "First tool",
async execute() {
return "a"
},
}),
defineCustomTool({
name: "tool_b",
description: "Second tool",
parameters: z.object({
value: z.number().describe("A numeric value"),
}),
async execute() {
return "b"
},
}),
]
const result = serializeCustomTools(tools)
expect(result).toHaveLength(2)
expect(result[0]?.name).toBe("tool_a")
expect(result[1]?.name).toBe("tool_b")
expect(result[1]?.parameters?.properties?.value).toBeDefined()
})
})
describe("Serialization snapshots", () => {
it("should correctly serialize simple tool", () => {
const result = serializeCustomTool(fixtureTools.simple)
expect(result).toMatchSnapshot()
})
it("should correctly serialize cached tool", () => {
const result = serializeCustomTool(fixtureTools.cached)
expect(result).toMatchSnapshot()
})
it("should correctly serialize legacy tool (using args)", () => {
const result = serializeCustomTool(fixtureTools.legacy)
expect(result).toMatchSnapshot()
})
it("should correctly serialize all fixtures", () => {
const result = Object.values(fixtureTools).map(serializeCustomTool)
expect(result).toMatchSnapshot()
})
})

View file

@ -0,0 +1,432 @@
/**
* CustomToolRegistry - A reusable class for dynamically loading and managing TypeScript tools.
*
* Features:
* - Dynamic TypeScript/JavaScript tool loading with esbuild transpilation.
* - Runtime validation of tool definitions.
* - Tool execution with context.
* - JSON Schema generation for LLM integration.
*/
import fs from "fs"
import path from "path"
import { createHash } from "crypto"
import os from "os"
import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolParametersSchema } from "@roo-code/types"
import type { StoredCustomTool, LoadResult } from "./types.js"
import { serializeCustomTool } from "./serialize.js"
import { runEsbuild, NODE_BUILTIN_MODULES, COMMONJS_REQUIRE_BANNER } from "./esbuild-runner.js"
export interface RegistryOptions {
/** Directory for caching compiled TypeScript files. */
cacheDir?: string
/** Additional paths for resolving node modules (useful for tools outside node_modules). */
nodePaths?: string[]
/** Path to the extension root directory (for finding bundled esbuild binary in production). */
extensionPath?: string
}
export class CustomToolRegistry {
private tools = new Map<string, StoredCustomTool>()
private tsCache = new Map<string, string>()
private cacheDir: string
private nodePaths: string[]
private extensionPath?: string
private lastLoaded: Map<string, number> = new Map()
constructor(options?: RegistryOptions) {
this.cacheDir = options?.cacheDir ?? path.join(os.tmpdir(), "dynamic-tools-cache")
// Default to current working directory's node_modules.
this.nodePaths = options?.nodePaths ?? [path.join(process.cwd(), "node_modules")]
this.extensionPath = options?.extensionPath
}
/**
* Load all tools from a directory.
* Supports both .ts and .js files.
*
* @param toolDir - Absolute path to the tools directory
* @returns LoadResult with lists of loaded and failed tools
*/
async loadFromDirectory(toolDir: string): Promise<LoadResult> {
const result: LoadResult = { loaded: [], failed: [] }
try {
if (!fs.existsSync(toolDir)) {
return result
}
const files = fs.readdirSync(toolDir).filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
for (const file of files) {
const filePath = path.join(toolDir, file)
try {
console.log(`[CustomToolRegistry] importing tool from ${filePath}`)
const mod = await this.import(filePath)
for (const [exportName, value] of Object.entries(mod)) {
const def = this.validate(exportName, value)
if (!def) {
continue
}
this.tools.set(def.name, { ...def, source: filePath })
console.log(`[CustomToolRegistry] loaded tool ${def.name} from ${filePath}`)
result.loaded.push(def.name)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[CustomToolRegistry] import(${filePath}) failed: ${message}`)
result.failed.push({ file, error: message })
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[CustomToolRegistry] loadFromDirectory(${toolDir}) failed: ${message}`)
}
return result
}
async loadFromDirectoryIfStale(toolDir: string): Promise<LoadResult> {
if (!fs.existsSync(toolDir)) {
return { loaded: [], failed: [] }
}
const lastLoaded = this.lastLoaded.get(toolDir)
const stat = fs.statSync(toolDir)
const isStale = lastLoaded ? stat.mtimeMs > lastLoaded : true
if (isStale) {
this.lastLoaded.set(toolDir, stat.mtimeMs)
return this.loadFromDirectory(toolDir)
}
return { loaded: this.list(), failed: [] }
}
/**
* Load all tools from multiple directories.
* Directories are processed in order, so later directories can override tools from earlier ones.
* Supports both .ts and .js files.
*
* @param toolDirs - Array of absolute paths to tools directories
* @returns LoadResult with lists of loaded and failed tools from all directories
*/
async loadFromDirectories(toolDirs: string[]): Promise<LoadResult> {
const result: LoadResult = { loaded: [], failed: [] }
for (const toolDir of toolDirs) {
const dirResult = await this.loadFromDirectory(toolDir)
result.loaded.push(...dirResult.loaded)
result.failed.push(...dirResult.failed)
}
return result
}
/**
* Load all tools from multiple directories if any has become stale.
* Directories are processed in order, so later directories can override tools from earlier ones.
*
* @param toolDirs - Array of absolute paths to tools directories
* @returns LoadResult with lists of loaded and failed tools
*/
async loadFromDirectoriesIfStale(toolDirs: string[]): Promise<LoadResult> {
const result: LoadResult = { loaded: [], failed: [] }
for (const toolDir of toolDirs) {
const dirResult = await this.loadFromDirectoryIfStale(toolDir)
result.loaded.push(...dirResult.loaded)
result.failed.push(...dirResult.failed)
}
return result
}
/**
* Register a tool directly (without loading from file).
*/
register(definition: CustomToolDefinition, source?: string): void {
const { name: id } = definition
const validated = this.validate(id, definition)
if (!validated) {
throw new Error(`Invalid tool definition for '${id}'`)
}
const storedTool: StoredCustomTool = source ? { ...validated, source } : validated
this.tools.set(id, storedTool)
}
/**
* Unregister a tool by ID.
*/
unregister(id: string): boolean {
return this.tools.delete(id)
}
/**
* Get a tool by ID.
*/
get(id: string): CustomToolDefinition | undefined {
return this.tools.get(id)
}
/**
* Check if a tool exists.
*/
has(id: string): boolean {
return this.tools.has(id)
}
/**
* Get all registered tool IDs.
*/
list(): string[] {
return Array.from(this.tools.keys())
}
/**
* Get all registered tools.
*/
getAll(): CustomToolDefinition[] {
return Array.from(this.tools.values())
}
/**
* Get all registered tools in the serialized format.
*/
getAllSerialized(): SerializedCustomToolDefinition[] {
return this.getAll().map(serializeCustomTool)
}
/**
* Get the number of registered tools.
*/
get size(): number {
return this.tools.size
}
/**
* Clear all registered tools.
*/
clear(): void {
this.tools.clear()
}
/**
* Set the extension path for finding bundled esbuild binary.
* This should be called with context.extensionPath when the extension activates.
*/
setExtensionPath(extensionPath: string): void {
this.extensionPath = extensionPath
}
/**
* Get the current extension path.
*/
getExtensionPath(): string | undefined {
return this.extensionPath
}
/**
* Clear the TypeScript compilation cache (both in-memory and on disk).
* This removes all tool-specific subdirectories and their contents.
*/
clearCache(): void {
this.tsCache.clear()
if (fs.existsSync(this.cacheDir)) {
try {
const entries = fs.readdirSync(this.cacheDir, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(this.cacheDir, entry.name)
if (entry.isDirectory()) {
// Remove tool-specific subdirectory and all its contents.
fs.rmSync(entryPath, { recursive: true, force: true })
} else if (entry.name.endsWith(".mjs")) {
// Also clean up any legacy flat .mjs files from older cache format.
fs.unlinkSync(entryPath)
}
}
} catch (error) {
console.error(
`[CustomToolRegistry] clearCache failed to clean disk cache: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}
/**
* Dynamically import a TypeScript or JavaScript file.
* TypeScript files are transpiled on-the-fly using esbuild.
*
* For TypeScript files, esbuild bundles the code with these considerations:
* - Node.js built-in modules (fs, path, etc.) are kept external
* - npm packages are bundled with a CommonJS shim for require() compatibility
* - The tool's local node_modules is included in the resolution path
*/
private async import(filePath: string): Promise<Record<string, CustomToolDefinition>> {
const absolutePath = path.resolve(filePath)
const ext = path.extname(absolutePath)
if (ext === ".js" || ext === ".mjs") {
return import(`file://${absolutePath}`)
}
const stat = fs.statSync(absolutePath)
const cacheKey = `${absolutePath}:${stat.mtimeMs}`
// Check if we have a cached version in memory.
if (this.tsCache.has(cacheKey)) {
const cachedPath = this.tsCache.get(cacheKey)!
return import(`file://${cachedPath}`)
}
const hash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 16)
// Use a tool-specific subdirectory to avoid .env file conflicts between tools.
const toolCacheDir = path.join(this.cacheDir, hash)
fs.mkdirSync(toolCacheDir, { recursive: true })
const tempFile = path.join(toolCacheDir, "bundle.mjs")
// Check if we have a cached version on disk (from a previous run/instance).
if (fs.existsSync(tempFile)) {
this.tsCache.set(cacheKey, tempFile)
return import(`file://${tempFile}`)
}
// Get the tool's directory to include its node_modules in resolution path.
const toolDir = path.dirname(absolutePath)
const toolNodeModules = path.join(toolDir, "node_modules")
// Combine default nodePaths with tool-specific node_modules.
// Tool's node_modules takes priority (listed first).
const nodePaths = fs.existsSync(toolNodeModules) ? [toolNodeModules, ...this.nodePaths] : this.nodePaths
// Bundle the TypeScript file with dependencies using esbuild CLI.
// - Node.js built-ins are external (they can't be bundled and are always available)
// - npm packages are bundled with CommonJS require() shim for compatibility
await runEsbuild(
{
entryPoint: absolutePath,
outfile: tempFile,
format: "esm",
platform: "node",
target: "node18",
bundle: true,
sourcemap: "inline",
packages: "bundle",
nodePaths,
external: NODE_BUILTIN_MODULES,
banner: COMMONJS_REQUIRE_BANNER,
},
this.extensionPath,
)
// Copy .env files from the tool's source directory to the tool-specific cache directory.
// This allows tools that use dotenv with __dirname to find their .env files,
// while ensuring different tools' .env files don't overwrite each other.
this.copyEnvFiles(toolDir, toolCacheDir)
this.tsCache.set(cacheKey, tempFile)
return import(`file://${tempFile}`)
}
/**
* Copy .env files from the tool's source directory to the tool-specific cache directory.
* This allows tools that use dotenv with __dirname to find their .env files,
* while ensuring different tools' .env files don't overwrite each other.
*
* @param toolDir - The directory containing the tool source files
* @param destDir - The tool-specific cache directory to copy .env files to
*/
private copyEnvFiles(toolDir: string, destDir: string): void {
try {
const files = fs.readdirSync(toolDir)
const envFiles = files.filter((f) => f === ".env" || f.startsWith(".env."))
for (const envFile of envFiles) {
const srcPath = path.join(toolDir, envFile)
const destPath = path.join(destDir, envFile)
// Only copy if source is a file (not a directory).
const stat = fs.statSync(srcPath)
if (stat.isFile()) {
fs.copyFileSync(srcPath, destPath)
console.log(`[CustomToolRegistry] copied ${envFile} to tool cache directory`)
}
}
} catch (error) {
// Non-fatal: log but don't fail if we can't copy env files.
console.warn(
`[CustomToolRegistry] failed to copy .env files: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
/**
* Check if a value is a Zod schema by looking for the _def property
* which is present on all Zod types.
*/
private isParametersSchema(value: unknown): value is CustomToolParametersSchema {
return (
value !== null &&
typeof value === "object" &&
"_def" in value &&
typeof (value as Record<string, unknown>)._def === "object"
)
}
/**
* Validate a tool definition and return a typed result.
* Returns null for non-tool exports, throws for invalid tools.
*/
private validate(exportName: string, value: unknown): CustomToolDefinition | null {
// Quick pre-check to filter out non-objects.
if (!value || typeof value !== "object") {
return null
}
// Check if it looks like a tool (has execute function).
if (!("execute" in value) || typeof (value as Record<string, unknown>).execute !== "function") {
return null
}
const obj = value as Record<string, unknown>
const errors: string[] = []
// Validate name.
if (typeof obj.name !== "string") {
errors.push("name: Expected string")
} else if (obj.name.length === 0) {
errors.push("name: Tool must have a non-empty name")
}
// Validate description.
if (typeof obj.description !== "string") {
errors.push("description: Expected string")
} else if (obj.description.length === 0) {
errors.push("description: Tool must have a non-empty description")
}
// Validate parameters (optional).
if (obj.parameters !== undefined && !this.isParametersSchema(obj.parameters)) {
errors.push("parameters: parameters must be a Zod schema")
}
if (errors.length > 0) {
throw new Error(`Invalid tool definition for '${exportName}': ${errors.join(", ")}`)
}
return value as CustomToolDefinition
}
}
export const customToolRegistry = new CustomToolRegistry()

View file

@ -0,0 +1,209 @@
/**
* esbuild-runner - Runs esbuild-wasm CLI to transpile TypeScript files.
*
* This module provides a way to run esbuild as a CLI process instead of using
* the JavaScript API. This uses esbuild-wasm which is cross-platform and works
* on all operating systems without needing native binaries.
*
* In production, the esbuild-wasm CLI script is bundled in dist/bin/.
* In development, it falls back to using esbuild-wasm from node_modules.
*/
import path from "path"
import fs from "fs"
import { builtinModules } from "module"
import { fileURLToPath } from "url"
import { execa } from "execa"
/**
* Node.js built-in modules that should never be bundled.
* These are always available in Node.js runtime and bundling them causes issues.
*
* Uses Node.js's authoritative list from `module.builtinModules` and adds
* the `node:` prefixed versions for comprehensive coverage.
*/
export const NODE_BUILTIN_MODULES: readonly string[] = [...builtinModules, ...builtinModules.map((m) => `node:${m}`)]
/**
* Banner code to add to bundled output.
* This provides a CommonJS-compatible `require` function for ESM bundles,
* which is needed when bundled npm packages use `require()` internally.
*/
export const COMMONJS_REQUIRE_BANNER = `import { createRequire as __roo_createRequire } from 'module';
var require = __roo_createRequire(import.meta.url);`
// Get the directory where this module is located.
function getModuleDir(): string | undefined {
try {
// In ESM context, import.meta.url is available.
// In bundled CJS, this will throw or be undefined.
if (typeof import.meta !== "undefined" && import.meta.url) {
return path.dirname(fileURLToPath(import.meta.url))
}
} catch {
// Ignore errors, fall through to undefined.
}
return undefined
}
const moduleDir = getModuleDir()
export interface EsbuildOptions {
/** Entry point file path (absolute) */
entryPoint: string
/** Output file path (absolute) */
outfile: string
/** Output format */
format?: "esm" | "cjs" | "iife"
/** Target platform */
platform?: "node" | "browser" | "neutral"
/** Target environment (e.g., "node18") */
target?: string
/** Bundle dependencies */
bundle?: boolean
/** Generate source maps */
sourcemap?: boolean | "inline" | "external"
/** How to handle packages: "bundle" includes them, "external" leaves them */
packages?: "bundle" | "external"
/** Additional paths for module resolution */
nodePaths?: string[]
/** Modules to exclude from bundling (resolved at runtime) */
external?: readonly string[]
/** JavaScript code to prepend to the output bundle */
banner?: string
}
/**
* Find the esbuild-wasm CLI script by walking up the directory tree.
* In pnpm monorepos, node_modules/esbuild-wasm is a symlink to the actual package,
* so we don't need special pnpm handling.
*/
function findEsbuildWasmScript(startDir: string): string | null {
const maxDepth = 10
let currentDir = path.resolve(startDir)
const root = path.parse(currentDir).root
for (let i = 0; i < maxDepth && currentDir !== root; i++) {
// Check node_modules/esbuild-wasm/bin/esbuild at this level.
const scriptPath = path.join(currentDir, "node_modules", "esbuild-wasm", "bin", "esbuild")
if (fs.existsSync(scriptPath)) {
return scriptPath
}
// Also check src/node_modules for monorepo where src is a workspace.
const srcScriptPath = path.join(currentDir, "src", "node_modules", "esbuild-wasm", "bin", "esbuild")
if (fs.existsSync(srcScriptPath)) {
return srcScriptPath
}
currentDir = path.dirname(currentDir)
}
return null
}
/**
* Get the path to the esbuild CLI script.
*
* Resolution order:
* 1. Production: Look in extension's dist/bin directory for bundled script.
* 2. Development: Use esbuild-wasm from node_modules (relative to this module).
* 3. Fallback: Try process.cwd() as last resort.
*
* @param extensionPath - Path to the extension's root directory (production)
* @returns Path to the esbuild CLI script
*/
export function getEsbuildScriptPath(extensionPath?: string): string {
// Production: look in extension's dist/bin directory.
if (extensionPath) {
const prodPath = path.join(extensionPath, "dist", "bin", "esbuild")
if (fs.existsSync(prodPath)) {
return prodPath
}
}
// Development: use esbuild-wasm from node_modules relative to this module.
// This works when running the extension in debug mode (if moduleDir is available).
if (moduleDir) {
const devPath = findEsbuildWasmScript(moduleDir)
if (devPath) {
return devPath
}
}
// Fallback: try from cwd (for tests and other contexts).
const cwdPath = findEsbuildWasmScript(process.cwd())
if (cwdPath) {
return cwdPath
}
throw new Error("esbuild-wasm CLI not found. Ensure esbuild-wasm is installed.")
}
/**
* Run esbuild CLI to bundle a TypeScript file.
*
* Uses esbuild-wasm which is cross-platform and runs via Node.js.
*
* @param options - Build options
* @param extensionPath - Path to extension root (for finding bundled script)
* @returns Promise that resolves when build completes
* @throws Error if the build fails
*/
export async function runEsbuild(options: EsbuildOptions, extensionPath?: string): Promise<void> {
const scriptPath = getEsbuildScriptPath(extensionPath)
const args: string[] = [
scriptPath,
options.entryPoint,
`--outfile=${options.outfile}`,
`--format=${options.format ?? "esm"}`,
`--platform=${options.platform ?? "node"}`,
`--target=${options.target ?? "node18"}`,
]
if (options.bundle !== false) {
args.push("--bundle")
}
if (options.sourcemap) {
args.push(options.sourcemap === true ? "--sourcemap" : `--sourcemap=${options.sourcemap}`)
}
if (options.packages) {
args.push(`--packages=${options.packages}`)
}
// Add external modules - these won't be bundled and will be resolved at runtime.
if (options.external && options.external.length > 0) {
for (const ext of options.external) {
args.push(`--external:${ext}`)
}
}
// Add banner code (e.g., for CommonJS require shim in ESM bundles).
if (options.banner) {
args.push(`--banner:js=${options.banner}`)
}
// Build environment with NODE_PATH for module resolution.
const env: NodeJS.ProcessEnv = { ...process.env }
if (options.nodePaths && options.nodePaths.length > 0) {
env.NODE_PATH = options.nodePaths.join(path.delimiter)
}
try {
await execa(process.execPath, args, { env, stdin: "ignore" })
} catch (error) {
const execaError = error as { stderr?: string; stdout?: string; exitCode?: number; message: string }
const errorMessage = execaError.stderr || execaError.stdout || `esbuild exited with code ${execaError.exitCode}`
throw new Error(`esbuild failed: ${errorMessage}`)
}
}

View file

@ -0,0 +1,23 @@
import type { OpenAI } from "openai"
import type { SerializedCustomToolDefinition } from "@roo-code/types"
export function formatNative(tool: SerializedCustomToolDefinition): OpenAI.Chat.ChatCompletionFunctionTool {
// Create a shallow copy to avoid mutating the input object
let parameters = tool.parameters
if (parameters) {
// Create a new object with the modifications instead of mutating the original
parameters = { ...parameters }
// We don't need the $schema property; none of the other tools specify it.
delete parameters["$schema"]
// https://community.openai.com/t/on-the-function-calling-what-about-if-i-have-no-parameter-to-call/516876
if (!parameters.required) {
parameters.required = []
}
}
return { type: "function", function: { ...tool, strict: true, parameters } }
}

View file

@ -0,0 +1,89 @@
import type { SerializedCustomToolDefinition, SerializedCustomToolParameters } from "@roo-code/types"
/**
* Extract the type string from a parameter schema.
* Handles both direct `type` property and `anyOf` schemas (used for nullable types).
*/
function getParameterType(parameter: SerializedCustomToolParameters): string {
// Direct type property
if (parameter.type) {
return String(parameter.type)
}
// Handle anyOf schema (used for nullable types like `string | null`)
if (parameter.anyOf && Array.isArray(parameter.anyOf)) {
const types = parameter.anyOf
.map((schema) => (typeof schema === "object" && schema.type ? String(schema.type) : null))
.filter((t): t is string => t !== null && t !== "null")
if (types.length > 0) {
return types.join(" | ")
}
}
return "unknown"
}
function getParameterDescription(name: string, parameter: SerializedCustomToolParameters, required: string[]): string {
const requiredText = required.includes(name) ? "(required)" : "(optional)"
const typeText = getParameterType(parameter)
return `- ${name}: ${requiredText} ${parameter.description ?? ""} (type: ${typeText})`
}
function getUsage(tool: SerializedCustomToolDefinition): string {
const lines: string[] = [`<${tool.name}>`]
if (tool.parameters) {
const required = tool.parameters.required ?? []
for (const [argName, _argType] of Object.entries(tool.parameters.properties ?? {})) {
const placeholder = required.includes(argName) ? `${argName} value here` : `optional ${argName} value`
lines.push(`<${argName}>${placeholder}</${argName}>`)
}
}
lines.push(`</${tool.name}>`)
return lines.join("\n")
}
function getDescription(tool: SerializedCustomToolDefinition): string {
const parts: string[] = []
parts.push(`## ${tool.name}`)
parts.push(`Description: ${tool.description}`)
if (tool.parameters?.properties) {
const required = tool.parameters?.required ?? []
parts.push("Parameters:")
for (const [name, parameter] of Object.entries(tool.parameters.properties)) {
// What should we do with `boolean` values for `parameter`?
if (typeof parameter !== "object") {
continue
}
parts.push(getParameterDescription(name, parameter, required))
}
} else {
parts.push("Parameters: None")
}
parts.push("Usage:")
parts.push(getUsage(tool))
return parts.join("\n")
}
export function formatXml(tools: SerializedCustomToolDefinition[]): string {
if (tools.length === 0) {
return ""
}
const descriptions = tools.map((tool) => getDescription(tool))
return `# Custom Tools
The following custom tools are available for this mode. Use them in the same way as built-in tools.
${descriptions.join("\n\n")}`
}

View file

@ -0,0 +1,4 @@
export * from "./custom-tool-registry.js"
export * from "./serialize.js"
export * from "./format-xml.js"
export * from "./format-native.js"

View file

@ -0,0 +1,21 @@
import { type SerializedCustomToolDefinition, parametersSchema } from "@roo-code/types"
import type { StoredCustomTool } from "./types.js"
export function serializeCustomTool({
name,
description,
parameters,
source,
}: StoredCustomTool): SerializedCustomToolDefinition {
return {
name,
description,
parameters: parameters ? parametersSchema.toJSONSchema(parameters) : undefined,
source,
}
}
export function serializeCustomTools(tools: StoredCustomTool[]): SerializedCustomToolDefinition[] {
return tools.map(serializeCustomTool)
}

View file

@ -0,0 +1,8 @@
import { type CustomToolDefinition } from "@roo-code/types"
export type StoredCustomTool = CustomToolDefinition & { source?: string }
export interface LoadResult {
loaded: string[]
failed: Array<{ file: string; error: string }>
}

View file

@ -0,0 +1 @@
export * from "./custom-tools/index.js"

View file

@ -0,0 +1,9 @@
{
"extends": "@roo-code/config-typescript/base.json",
"compilerOptions": {
"types": ["vitest/globals"],
"outDir": "dist"
},
"include": ["src", "scripts", "*.config.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: true,
environment: "node",
watch: false,
},
})

View file

@ -1,14 +1,14 @@
FROM node:20-slim AS base
# Build with:
# docker compose -f packages/evals/docker-compose.yml build runner
# Install pnpm
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN npm install -g npm@latest npm-run-all
# Test with:
# docker compose -f packages/evals/docker-compose.yml run --rm runner bash
# Install system packages
RUN apt update && \
apt install -y \
FROM debian:bookworm-slim AS base
# Install system packages (excluding language runtimes - those come from mise)
RUN apt-get update && \
apt-get install -y \
curl \
git \
vim \
@ -22,18 +22,13 @@ RUN apt update && \
gpg \
xvfb \
cmake \
golang-go \
default-jre \
python3 \
python3-venv \
python3-dev \
python3-pip \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install Docker cli
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt update && apt install -y docker-ce-cli \
&& apt-get update && apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*
# Install VS Code
@ -41,15 +36,43 @@ RUN wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor
&& install -D -o root -g root -m 644 packages.microsoft.gpg /etc/apt/keyrings/packages.microsoft.gpg \
&& echo "deb [arch=amd64,arm64,armhf signed-by=/etc/apt/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main" | tee /etc/apt/sources.list.d/vscode.list > /dev/null \
&& rm -f packages.microsoft.gpg \
&& apt update && apt install -y code \
&& apt-get update && apt-get install -y code \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /roo
# Install rust
ARG RUST_VERSION=1.87.0
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain ${RUST_VERSION} \
&& echo 'source $HOME/.cargo/env' >> $HOME/.bashrc
# Install mise (https://mise.jdx.dev) for language runtime management
RUN curl https://mise.run | sh \
&& /root/.local/bin/mise --version
# Set up mise environment
ENV MISE_DATA_DIR="/root/.local/share/mise"
ENV PATH="/root/.local/share/mise/shims:/root/.local/bin:$PATH"
# Define language runtime versions (matching setup.sh)
ARG NODE_VERSION=20.19.2
ARG PYTHON_VERSION=3.13.2
ARG GO_VERSION=1.24.2
ARG RUST_VERSION=1.85.1
ARG JAVA_VERSION=openjdk-17
ARG UV_VERSION=0.7.11
# Install language runtimes via mise
RUN mise use --global node@${NODE_VERSION} \
&& mise use --global python@${PYTHON_VERSION} \
&& mise use --global go@${GO_VERSION} \
&& mise use --global rust@${RUST_VERSION} \
&& mise use --global java@${JAVA_VERSION} \
&& mise use --global uv@${UV_VERSION} \
&& mise reshim
# Verify installations
RUN node --version && python --version && go version && rustc --version && java --version && uv --version
# Install pnpm (after node is available from mise)
ENV PNPM_HOME="/root/.local/share/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm npm-run-all
# Install VS Code extensions
ARG GOLANG_EXT_VERSION=0.46.1
@ -72,25 +95,30 @@ RUN git clone ${EVALS_REPO_URL} evals \
&& cd evals \
&& git checkout ${EVALS_COMMIT}
# Install uv and sync python dependencies
ARG UV_VERSION=0.7.11
# Pre-warm Gradle wrapper cache (./gradlew downloads its own Gradle regardless of system install).
# Find a Java project with gradlew and run it to cache the distribution.
RUN find /roo/evals -name "gradlew" -type f | head -1 | xargs -I {} sh -c 'cd $(dirname {}) && ./gradlew --version'
# Sync python dependencies for evals
WORKDIR /roo/evals/python
RUN curl -LsSf https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-installer.sh | sh \
&& /root/.local/bin/uv sync
RUN uv sync
WORKDIR /roo/repo
# Install npm packages
RUN mkdir -p \
scripts \
apps/cli \
packages/build \
packages/config-eslint \
packages/config-typescript \
packages/core \
packages/evals \
packages/ipc \
packages/telemetry \
packages/types \
packages/cloud \
packages/vscode-shim \
src \
webview-ui
@ -98,14 +126,17 @@ COPY ./package.json ./
COPY ./pnpm-lock.yaml ./
COPY ./pnpm-workspace.yaml ./
COPY ./scripts/bootstrap.mjs ./scripts/
COPY ./apps/cli/package.json ./apps/cli/
COPY ./packages/build/package.json ./packages/build/
COPY ./packages/config-eslint/package.json ./packages/config-eslint/
COPY ./packages/config-typescript/package.json ./packages/config-typescript/
COPY ./packages/core/package.json ./packages/core/
COPY ./packages/evals/package.json ./packages/evals/
COPY ./packages/ipc/package.json ./packages/ipc/
COPY ./packages/telemetry/package.json ./packages/telemetry/
COPY ./packages/types/package.json ./packages/types/
COPY ./packages/cloud/package.json ./packages/cloud/
COPY ./packages/vscode-shim/package.json ./packages/vscode-shim/
COPY ./src/package.json ./src/
COPY ./webview-ui/package.json ./webview-ui/
@ -126,10 +157,15 @@ COPY packages/evals/.env.local ./packages/evals/
# Copy the pre-installed VS Code extensions
RUN cp -r /roo/.vscode-template /roo/.vscode
# Build the Roo Code extension
# Build the Roo Code extension (for VSCode execution method)
RUN pnpm vsix -- --out ../bin/roo-code.vsix \
&& yes | code --no-sandbox --user-data-dir /roo/.vscode --install-extension bin/roo-code.vsix
# Build the extension bundle and CLI (for CLI execution method)
# The CLI requires the extension bundle (src/dist/extension.js) and the CLI build (apps/cli/dist/index.js)
RUN pnpm --filter roo-cline bundle \
&& pnpm --filter @roo-code/cli build
# Copy entrypoint script
COPY packages/evals/.docker/entrypoints/runner.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

View file

@ -0,0 +1,35 @@
import { MessageLogDeduper } from "../messageLogDeduper.js"
describe("MessageLogDeduper", () => {
it("dedupes identical messages for same action+ts", () => {
const d = new MessageLogDeduper()
const msg = { ts: 123, type: "say", say: "reasoning", text: "hello", partial: false }
expect(d.shouldLog("updated", msg)).toBe(true)
expect(d.shouldLog("updated", msg)).toBe(false)
})
it("logs again if payload changes for same action+ts", () => {
const d = new MessageLogDeduper()
expect(d.shouldLog("updated", { ts: 123, text: "a" })).toBe(true)
expect(d.shouldLog("updated", { ts: 123, text: "b" })).toBe(true)
})
it("does not dedupe across different actions", () => {
const d = new MessageLogDeduper()
const msg = { ts: 123, text: "same" }
expect(d.shouldLog("created", msg)).toBe(true)
expect(d.shouldLog("updated", msg)).toBe(true)
})
it("evicts oldest entries", () => {
const d = new MessageLogDeduper(2)
expect(d.shouldLog("updated", { ts: 1, text: "a" })).toBe(true)
expect(d.shouldLog("updated", { ts: 2, text: "b" })).toBe(true)
// causes eviction of ts:1
expect(d.shouldLog("updated", { ts: 3, text: "c" })).toBe(true)
// ts:1 was evicted so it should log again
expect(d.shouldLog("updated", { ts: 1, text: "a" })).toBe(true)
})
})

View file

@ -6,7 +6,7 @@ import { EVALS_REPO_PATH } from "../exercises/index.js"
import { runCi } from "./runCi.js"
import { runEvals } from "./runEvals.js"
import { processTask } from "./runTask.js"
import { processTask } from "./processTask.js"
const main = async () => {
await run(

View file

@ -0,0 +1,50 @@
export class MessageLogDeduper {
private readonly lastLoggedByKey = new Map<string, string>()
constructor(private readonly maxEntries = 10_000) {}
/**
* Returns true if this message should be logged.
* Dedupe key: `${action}:${message.ts}`.
* Dedupe rule: skip if payload is identical to the last logged payload for that key.
*/
public shouldLog(action: string | undefined, message: unknown): boolean {
if (!action || !message || typeof message !== "object") {
return true
}
const ts = (message as { ts?: unknown }).ts
if (typeof ts !== "number") {
return true
}
let serialized: string
try {
serialized = JSON.stringify(message)
} catch {
// If serialization fails, prefer logging.
return true
}
const key = `${action}:${ts}`
const prev = this.lastLoggedByKey.get(key)
if (prev === serialized) {
return false
}
// Refresh insertion order so eviction removes true oldest.
if (this.lastLoggedByKey.has(key)) {
this.lastLoggedByKey.delete(key)
}
this.lastLoggedByKey.set(key, serialized)
if (this.lastLoggedByKey.size > this.maxEntries) {
const oldestKey = this.lastLoggedByKey.keys().next().value as string | undefined
if (oldestKey) {
this.lastLoggedByKey.delete(oldestKey)
}
}
return true
}
}

View file

@ -0,0 +1,150 @@
import { execa } from "execa"
import { type TaskEvent, RooCodeEventName } from "@roo-code/types"
import { findRun, findTask, updateTask } from "../db/index.js"
import { Logger, getTag, isDockerContainer } from "./utils.js"
import { redisClient, getPubSubKey, registerRunner, deregisterRunner } from "./redis.js"
import { runUnitTest } from "./runUnitTest.js"
import { runTaskWithCli } from "./runTaskInCli.js"
import { runTaskInVscode } from "./runTaskInVscode.js"
export const processTask = async ({
taskId,
jobToken,
logger,
}: {
taskId: number
jobToken: string | null
logger?: Logger
}) => {
const task = await findTask(taskId)
const { language, exercise } = task
const run = await findRun(task.runId)
await registerRunner({ runId: run.id, taskId, timeoutSeconds: (run.timeout || 5) * 60 })
const containerized = isDockerContainer()
logger =
logger ||
new Logger({
logDir: containerized ? `/var/log/evals/runs/${run.id}` : `/tmp/evals/runs/${run.id}`,
filename: `${language}-${exercise}.log`,
tag: getTag("runTask", { run, task }),
})
try {
const publish = async (e: TaskEvent) => {
const redis = await redisClient()
await redis.publish(getPubSubKey(run.id), JSON.stringify(e))
}
const executionMethod = run.executionMethod || "vscode"
logger.info(`running task ${task.id} (${language}/${exercise}) via ${executionMethod}...`)
if (executionMethod === "cli") {
await runTaskWithCli({ run, task, jobToken, publish, logger })
} else {
await runTaskInVscode({ run, task, jobToken, publish, logger })
}
logger.info(`testing task ${task.id} (${language}/${exercise})...`)
const passed = await runUnitTest({ task, logger })
logger.info(`task ${task.id} (${language}/${exercise}) -> ${passed}`)
await updateTask(task.id, { passed })
await publish({
eventName: passed ? RooCodeEventName.EvalPass : RooCodeEventName.EvalFail,
taskId: task.id,
})
} finally {
await deregisterRunner({ runId: run.id, taskId })
}
}
export const processTaskInContainer = async ({
taskId,
jobToken,
logger,
maxRetries = 10,
}: {
taskId: number
jobToken: string | null
logger: Logger
maxRetries?: number
}) => {
const baseArgs = [
"--rm",
"--network evals_default",
"-v /var/run/docker.sock:/var/run/docker.sock",
"-v /tmp/evals:/var/log/evals",
"-e HOST_EXECUTION_METHOD=docker",
]
if (jobToken) {
baseArgs.push(`-e ROO_CODE_CLOUD_TOKEN=${jobToken}`)
}
// Pass API keys to the container so the CLI can authenticate
const apiKeyEnvVars = [
"OPENROUTER_API_KEY",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
"DEEPSEEK_API_KEY",
"MISTRAL_API_KEY",
]
for (const envVar of apiKeyEnvVars) {
if (process.env[envVar]) {
baseArgs.push(`-e ${envVar}=${process.env[envVar]}`)
}
}
const command = `pnpm --filter @roo-code/evals cli --taskId ${taskId}`
logger.info(command)
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const containerName = `evals-task-${taskId}.${attempt}`
const args = [`--name ${containerName}`, `-e EVALS_ATTEMPT=${attempt}`, ...baseArgs]
const isRetry = attempt > 0
if (isRetry) {
const delayMs = Math.pow(2, attempt - 1) * 1000 * (0.5 + Math.random())
logger.info(`retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`)
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
logger.info(
`${isRetry ? "retrying" : "executing"} container command (attempt ${attempt + 1}/${maxRetries + 1})`,
)
const subprocess = execa(`docker run ${args.join(" ")} evals-runner sh -c "${command}"`, { shell: true })
// subprocess.stdout?.on("data", (data) => console.log(data.toString()))
// subprocess.stderr?.on("data", (data) => console.error(data.toString()))
try {
const result = await subprocess
logger.info(`container process completed with exit code: ${result.exitCode}`)
return
} catch (error) {
if (error && typeof error === "object" && "exitCode" in error) {
logger.error(
`container process failed with exit code: ${error.exitCode} (attempt ${attempt + 1}/${maxRetries + 1})`,
)
} else {
logger.error(`container process failed with error: ${error} (attempt ${attempt + 1}/${maxRetries + 1})`)
}
if (attempt === maxRetries) {
break
}
}
}
logger.error(`all ${maxRetries + 1} attempts failed, giving up`)
// TODO: Mark task as failed.
}

View file

@ -5,7 +5,7 @@ import { EVALS_REPO_PATH } from "../exercises/index.js"
import { Logger, getTag, isDockerContainer, resetEvalsRepo, commitEvalsRepoChanges } from "./utils.js"
import { startHeartbeat, stopHeartbeat } from "./redis.js"
import { processTask, processTaskInContainer } from "./runTask.js"
import { processTask, processTaskInContainer } from "./processTask.js"
export const runEvals = async (runId: number) => {
const run = await findRun(runId)
@ -53,13 +53,18 @@ export const runEvals = async (runId: number) => {
}
try {
// Add tasks with staggered start times when concurrency > 1
// Add tasks with staggered start times when concurrency > 1.
for (let i = 0; i < filteredTasks.length; i++) {
const task = filteredTasks[i]
if (!task) continue
if (!task) {
continue
}
if (run.concurrency > 1 && i > 0) {
await new Promise((resolve) => setTimeout(resolve, STAGGER_DELAY_MS))
}
queue.add(createTaskRunner(task))
}

View file

@ -0,0 +1,313 @@
import * as fs from "fs"
import * as path from "path"
import * as os from "node:os"
import pWaitFor from "p-wait-for"
import { execa } from "execa"
import { type ToolUsage, TaskCommandName, RooCodeEventName, IpcMessageType } from "@roo-code/types"
import { IpcClient } from "@roo-code/ipc"
import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index.js"
import { EVALS_REPO_PATH } from "../exercises/index.js"
import { type RunTaskOptions } from "./types.js"
import { mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js"
/**
* Run a task using the Roo Code CLI (headless mode).
* Uses the same IPC protocol as VSCode since the CLI loads the same extension bundle.
*/
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 workspacePath = path.resolve(EVALS_REPO_PATH, language, exercise)
const ipcSocketPath = path.resolve(os.tmpdir(), `evals-cli-${run.id}-${task.id}.sock`)
const env: Record<string, string> = {
...(process.env as Record<string, string>),
ROO_CODE_IPC_SOCKET_PATH: ipcSocketPath,
}
if (jobToken) {
env.ROO_CODE_CLOUD_TOKEN = jobToken
}
const controller = new AbortController()
const cancelSignal = controller.signal
const cliArgs = [
"--filter",
"@roo-code/cli",
"start",
"--yes",
"--exit-on-complete",
"--reasoning-effort",
"disabled",
"--workspace",
workspacePath,
]
if (run.settings?.mode) {
cliArgs.push("-M", run.settings.mode)
}
if (run.settings?.apiProvider) {
cliArgs.push("-p", run.settings.apiProvider)
}
const modelId = run.settings?.apiModelId || run.settings?.openRouterModelId
if (modelId) {
cliArgs.push("-m", 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.
let stdoutBuffer = ""
let stderrBuffer = ""
// Track subprocess exit code - with -x flag the CLI exits immediately after task completion.
let subprocessExitCode: number | null = null
// Pipe CLI stdout/stderr to the logger for easier debugging.
// Buffer output and only log complete lines to avoid fragmented token-by-token logging.
// Use logger.raw() to output without the verbose prefix (timestamp, tag, etc).
subprocess.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString()
const lines = stdoutBuffer.split("\n")
// Keep the last incomplete line in the buffer.
stdoutBuffer = lines.pop() || ""
// Log all complete lines without the verbose prefix.
for (const line of lines) {
if (line.trim()) {
logger.raw(line)
}
}
})
subprocess.stderr?.on("data", (data: Buffer) => {
stderrBuffer += data.toString()
const lines = stderrBuffer.split("\n")
// Keep the last incomplete line in the buffer.
stderrBuffer = lines.pop() || ""
// Log all complete lines without the verbose prefix.
for (const line of lines) {
if (line.trim()) {
logger.raw(line)
}
}
})
// Log any remaining buffered output when the subprocess exits.
subprocess.on("exit", (code) => {
subprocessExitCode = code
if (stdoutBuffer.trim()) {
logger.raw(stdoutBuffer)
}
if (stderrBuffer.trim()) {
logger.raw(stderrBuffer)
}
})
// Give CLI some time to start and create IPC server.
await new Promise((resolve) => setTimeout(resolve, 5_000))
let client: IpcClient | undefined = undefined
let attempts = 10 // More attempts for CLI startup.
while (true) {
try {
client = new IpcClient(ipcSocketPath)
await pWaitFor(() => client!.isReady, { interval: 500, timeout: 2_000 })
break
} catch (_error) {
client?.disconnect()
attempts--
if (attempts <= 0) {
logger.error(`unable to connect to IPC socket -> ${ipcSocketPath}`)
throw new Error("Unable to connect to CLI IPC socket.")
}
// Wait a bit before retrying.
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
}
// For CLI mode, we need to create taskMetrics immediately because the CLI starts
// the task right away (from command line args). By the time we connect to IPC,
// the TaskStarted event may have already been sent and missed.
// This is different from VSCode mode where we send StartNewTask via IPC and can
// reliably receive TaskStarted.
const taskMetrics = await createTaskMetrics({
cost: 0,
tokensIn: 0,
tokensOut: 0,
tokensContext: 0,
duration: 0,
cacheWrites: 0,
cacheReads: 0,
})
await updateTask(task.id, { taskMetricsId: taskMetrics.id, startedAt: new Date() })
logger.info(`created taskMetrics with id ${taskMetrics.id}`)
// The rest of the logic handles IPC events for metrics updates.
let taskStartedAt = Date.now()
let taskFinishedAt: number | undefined
let taskAbortedAt: number | undefined
let taskTimedOut: boolean = false
const taskMetricsId = taskMetrics.id // Already set, no need to wait for TaskStarted.
let rooTaskId: string | undefined
let isClientDisconnected = false
const accumulatedToolUsage: ToolUsage = {}
// For CLI mode, we don't need verbose IPC message logging since we're logging stdout instead.
// We only track what's needed for metrics and task state management.
const ignoreEventsForBroadcast = [RooCodeEventName.Message]
let isApiUnstable = false
client.on(IpcMessageType.TaskEvent, async (taskEvent) => {
const { eventName, payload } = taskEvent
// Track API instability for retry logic.
if (
eventName === RooCodeEventName.Message &&
payload[0].message.say &&
["api_req_retry_delayed", "api_req_retried"].includes(payload[0].message.say)
) {
isApiUnstable = true
}
// Publish events to Redis (except Message events) for the web UI.
if (!ignoreEventsForBroadcast.includes(eventName)) {
await publish({ ...taskEvent, taskId: task.id })
}
// Handle task lifecycle events.
// For CLI mode, we already created taskMetrics before connecting to IPC,
// but we still want to capture the rooTaskId from TaskStarted if we receive it.
if (eventName === RooCodeEventName.TaskStarted) {
taskStartedAt = Date.now()
rooTaskId = payload[0]
logger.info(`received TaskStarted event, rooTaskId: ${rooTaskId}`)
}
if (eventName === RooCodeEventName.TaskToolFailed) {
const [_taskId, toolName, error] = payload
await createToolError({ taskId: task.id, toolName, error })
}
if (eventName === RooCodeEventName.TaskTokenUsageUpdated || eventName === RooCodeEventName.TaskCompleted) {
// In CLI mode, taskMetricsId is always set before we register event handlers.
const duration = Date.now() - taskStartedAt
const { totalCost, totalTokensIn, totalTokensOut, contextTokens, totalCacheWrites, totalCacheReads } =
payload[1]
const incomingToolUsage: ToolUsage = payload[2] ?? {}
mergeToolUsage(accumulatedToolUsage, incomingToolUsage)
await updateTaskMetrics(taskMetricsId, {
cost: totalCost,
tokensIn: totalTokensIn,
tokensOut: totalTokensOut,
tokensContext: contextTokens,
duration,
cacheWrites: totalCacheWrites ?? 0,
cacheReads: totalCacheReads ?? 0,
toolUsage: accumulatedToolUsage,
})
}
if (eventName === RooCodeEventName.TaskAborted) {
taskAbortedAt = Date.now()
}
if (eventName === RooCodeEventName.TaskCompleted) {
taskFinishedAt = Date.now()
}
})
client.on(IpcMessageType.Disconnect, async () => {
logger.info(`disconnected from IPC socket -> ${ipcSocketPath}`)
isClientDisconnected = true
// Note: In CLI mode, we don't need to resolve taskMetricsReady since
// taskMetrics is created synchronously before event handlers are registered.
})
// Note: We do NOT send StartNewTask via IPC here because the CLI already
// starts the task from its command line arguments. The IPC connection is
// only used to receive events (TaskStarted, TaskCompleted, etc.) and metrics.
// Sending StartNewTask here would start a SECOND task.
try {
const timeoutMs = (run.timeout || 5) * 60 * 1_000
await pWaitFor(() => !!taskFinishedAt || !!taskAbortedAt || isClientDisconnected, {
interval: 1_000,
timeout: timeoutMs,
})
} catch (_error) {
taskTimedOut = true
logger.error("time limit reached")
if (rooTaskId && !isClientDisconnected) {
logger.info("cancelling task")
client.sendCommand({ commandName: TaskCommandName.CancelTask, data: rooTaskId })
await new Promise((resolve) => setTimeout(resolve, 5_000))
}
taskFinishedAt = Date.now()
}
if (!taskFinishedAt && !taskTimedOut) {
// With -x flag, CLI exits immediately after task completion, which can cause
// IPC disconnection before we receive the TaskCompleted event.
// If subprocess exited cleanly (code 0), treat as successful completion.
if (subprocessExitCode === 0) {
taskFinishedAt = Date.now()
logger.info("subprocess exited cleanly (code 0), treating as task completion")
} else {
logger.error(`client disconnected before task finished (subprocess exit code: ${subprocessExitCode})`)
throw new Error("Client disconnected before task completion.")
}
}
logger.info("setting task finished at")
await updateTask(task.id, { finishedAt: new Date() })
if (rooTaskId && !isClientDisconnected) {
logger.info("closing task")
client.sendCommand({ commandName: TaskCommandName.CloseTask, data: rooTaskId })
await new Promise((resolve) => setTimeout(resolve, 2_000))
}
if (!isClientDisconnected) {
logger.info("disconnecting client")
client.disconnect()
}
logger.info("waiting for subprocess to finish")
controller.abort()
await waitForSubprocessWithTimeout({ subprocess, logger })
logger.close()
if (isApiUnstable && !taskFinishedAt) {
throw new Error("API is unstable, throwing to trigger a retry.")
}
}

View file

@ -1,5 +1,4 @@
import * as fs from "fs"
import * as fsp from "fs/promises"
import * as path from "path"
import * as os from "node:os"
@ -7,217 +6,23 @@ import pWaitFor from "p-wait-for"
import { execa } from "execa"
import {
type TaskEvent,
type ClineSay,
type ToolUsage,
TaskCommandName,
RooCodeEventName,
IpcMessageType,
EVALS_SETTINGS,
type ToolUsage,
} from "@roo-code/types"
import { IpcClient } from "@roo-code/ipc"
import {
type Run,
type Task,
findRun,
findTask,
updateTask,
createTaskMetrics,
updateTaskMetrics,
createToolError,
} from "../db/index.js"
import { updateTask, createTaskMetrics, updateTaskMetrics, createToolError } from "../db/index.js"
import { EVALS_REPO_PATH } from "../exercises/index.js"
import { Logger, getTag, isDockerContainer } from "./utils.js"
import { redisClient, getPubSubKey, registerRunner, deregisterRunner } from "./redis.js"
import { runUnitTest } from "./runUnitTest.js"
import { type RunTaskOptions } from "./types.js"
import { isDockerContainer, copyConversationHistory, mergeToolUsage, waitForSubprocessWithTimeout } from "./utils.js"
import { MessageLogDeduper } from "./messageLogDeduper.js"
class SubprocessTimeoutError extends Error {
constructor(timeout: number) {
super(`Subprocess timeout after ${timeout}ms`)
this.name = "SubprocessTimeoutError"
}
}
/**
* Copy conversation history files from VS Code extension storage to the log directory.
* This allows us to preserve the api_conversation_history.json and ui_messages.json
* files for post-mortem analysis alongside the log files.
*/
async function copyConversationHistory({
rooTaskId,
logDir,
language,
exercise,
iteration,
logger,
}: {
rooTaskId: string
logDir: string
language: string
exercise: string
iteration: number
logger: Logger
}): Promise<void> {
// VS Code extension global storage path within the container
const extensionStoragePath = "/roo/.vscode/User/globalStorage/rooveterinaryinc.roo-cline"
const taskStoragePath = path.join(extensionStoragePath, "tasks", rooTaskId)
const filesToCopy = ["api_conversation_history.json", "ui_messages.json"]
for (const filename of filesToCopy) {
const sourcePath = path.join(taskStoragePath, filename)
// Use sanitized exercise name (replace slashes with dashes) for the destination filename
// Include iteration number to handle multiple attempts at the same exercise
const sanitizedExercise = exercise.replace(/\//g, "-")
const destFilename = `${language}-${sanitizedExercise}.${iteration}_${filename}`
const destPath = path.join(logDir, destFilename)
try {
// Check if source file exists
await fsp.access(sourcePath)
// Copy the file
await fsp.copyFile(sourcePath, destPath)
logger.info(`copied ${filename} to ${destPath}`)
} catch (error) {
// File may not exist if task didn't complete properly - this is not fatal
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
logger.info(`${filename} not found at ${sourcePath} - skipping`)
} else {
logger.error(`failed to copy ${filename}:`, error)
}
}
}
}
export const processTask = async ({
taskId,
jobToken,
logger,
}: {
taskId: number
jobToken: string | null
logger?: Logger
}) => {
const task = await findTask(taskId)
const { language, exercise } = task
const run = await findRun(task.runId)
await registerRunner({ runId: run.id, taskId, timeoutSeconds: (run.timeout || 5) * 60 })
const containerized = isDockerContainer()
logger =
logger ||
new Logger({
logDir: containerized ? `/var/log/evals/runs/${run.id}` : `/tmp/evals/runs/${run.id}`,
filename: `${language}-${exercise}.log`,
tag: getTag("runTask", { run, task }),
})
try {
const publish = async (e: TaskEvent) => {
const redis = await redisClient()
await redis.publish(getPubSubKey(run.id), JSON.stringify(e))
}
logger.info(`running task ${task.id} (${language}/${exercise})...`)
await runTask({ run, task, jobToken, publish, logger })
logger.info(`testing task ${task.id} (${language}/${exercise})...`)
const passed = await runUnitTest({ task, logger })
logger.info(`task ${task.id} (${language}/${exercise}) -> ${passed}`)
await updateTask(task.id, { passed })
await publish({
eventName: passed ? RooCodeEventName.EvalPass : RooCodeEventName.EvalFail,
taskId: task.id,
})
} finally {
await deregisterRunner({ runId: run.id, taskId })
}
}
export const processTaskInContainer = async ({
taskId,
jobToken,
logger,
maxRetries = 10,
}: {
taskId: number
jobToken: string | null
logger: Logger
maxRetries?: number
}) => {
const baseArgs = [
"--rm",
"--network evals_default",
"-v /var/run/docker.sock:/var/run/docker.sock",
"-v /tmp/evals:/var/log/evals",
"-e HOST_EXECUTION_METHOD=docker",
]
if (jobToken) {
baseArgs.push(`-e ROO_CODE_CLOUD_TOKEN=${jobToken}`)
}
const command = `pnpm --filter @roo-code/evals cli --taskId ${taskId}`
logger.info(command)
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const containerName = `evals-task-${taskId}.${attempt}`
const args = [`--name ${containerName}`, `-e EVALS_ATTEMPT=${attempt}`, ...baseArgs]
const isRetry = attempt > 0
if (isRetry) {
const delayMs = Math.pow(2, attempt - 1) * 1000 * (0.5 + Math.random())
logger.info(`retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`)
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
logger.info(
`${isRetry ? "retrying" : "executing"} container command (attempt ${attempt + 1}/${maxRetries + 1})`,
)
const subprocess = execa(`docker run ${args.join(" ")} evals-runner sh -c "${command}"`, { shell: true })
// subprocess.stdout?.on("data", (data) => console.log(data.toString()))
// subprocess.stderr?.on("data", (data) => console.error(data.toString()))
try {
const result = await subprocess
logger.info(`container process completed with exit code: ${result.exitCode}`)
return
} catch (error) {
if (error && typeof error === "object" && "exitCode" in error) {
logger.error(
`container process failed with exit code: ${error.exitCode} (attempt ${attempt + 1}/${maxRetries + 1})`,
)
} else {
logger.error(`container process failed with error: ${error} (attempt ${attempt + 1}/${maxRetries + 1})`)
}
if (attempt === maxRetries) {
break
}
}
}
logger.error(`all ${maxRetries + 1} attempts failed, giving up`)
// TODO: Mark task as failed.
}
type RunTaskOptions = {
run: Run
task: Task
jobToken: string | null
publish: (taskEvent: TaskEvent) => Promise<void>
logger: Logger
}
export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskOptions) => {
export const runTaskInVscode = 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 workspacePath = path.resolve(EVALS_REPO_PATH, language, exercise)
@ -300,11 +105,13 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
"diff_error",
"condense_context",
"condense_context_error",
"api_req_rate_limit_wait",
"api_req_retry_delayed",
"api_req_retried",
]
let isApiUnstable = false
const messageLogDeduper = new MessageLogDeduper()
client.on(IpcMessageType.TaskEvent, async (taskEvent) => {
const { eventName, payload } = taskEvent
@ -330,6 +137,15 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
(payload[0].message.say && loggableSays.includes(payload[0].message.say)) ||
payload[0].message.partial !== true)
) {
// Dedupe identical repeated message events (same message.ts + same payload)
if (eventName === RooCodeEventName.Message) {
const action = payload[0]?.action as string | undefined
const message = payload[0]?.message
if (!messageLogDeduper.shouldLog(action, message)) {
return
}
}
// Extract tool name for tool-related messages for clearer logging
let logEventName: string = eventName
if (eventName === RooCodeEventName.Message && payload[0]?.message?.ask === "tool") {
@ -398,24 +214,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
// For both TaskTokenUsageUpdated and TaskCompleted: toolUsage is payload[2]
const incomingToolUsage: ToolUsage = payload[2] ?? {}
// Merge incoming tool usage with accumulated data using MAX strategy.
// This handles the case where a task is rehydrated after abort:
// - Empty rehydrated data won't overwrite existing: max(5, 0) = 5
// - Legitimate restart with additional work is captured: max(5, 8) = 8
// Each task instance tracks its own cumulative values, so we take the max
// to preserve the highest values seen across all instances.
for (const [toolName, usage] of Object.entries(incomingToolUsage)) {
const existing = accumulatedToolUsage[toolName as keyof ToolUsage]
if (existing) {
accumulatedToolUsage[toolName as keyof ToolUsage] = {
attempts: Math.max(existing.attempts, usage.attempts),
failures: Math.max(existing.failures, usage.failures),
}
} else {
accumulatedToolUsage[toolName as keyof ToolUsage] = { ...usage }
}
}
mergeToolUsage(accumulatedToolUsage, incomingToolUsage)
await updateTaskMetrics(taskMetricsId, {
cost: totalCost,
@ -502,35 +301,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
logger.info("waiting for subprocess to finish")
controller.abort()
// Wait for subprocess to finish gracefully, with a timeout.
const SUBPROCESS_TIMEOUT = 10_000
try {
await Promise.race([
subprocess,
new Promise((_, reject) =>
setTimeout(() => reject(new SubprocessTimeoutError(SUBPROCESS_TIMEOUT)), SUBPROCESS_TIMEOUT),
),
])
logger.info("subprocess finished gracefully")
} catch (error) {
if (error instanceof SubprocessTimeoutError) {
logger.error("subprocess did not finish within timeout, force killing")
try {
if (subprocess.kill("SIGKILL")) {
logger.info("SIGKILL sent to subprocess")
} else {
logger.error("failed to send SIGKILL to subprocess")
}
} catch (killError) {
logger.error("subprocess.kill(SIGKILL) failed:", killError)
}
} else {
throw error
}
}
await waitForSubprocessWithTimeout({ subprocess, logger })
// Copy conversation history files from VS Code extension storage to the log directory
// for post-mortem analysis. Only do this in containerized mode where we have a known path.

View file

@ -0,0 +1,19 @@
import { type TaskEvent } from "@roo-code/types"
import type { Run, Task } from "../db/index.js"
import { Logger } from "./utils.js"
export class SubprocessTimeoutError extends Error {
constructor(timeout: number) {
super(`Subprocess timeout after ${timeout}ms`)
this.name = "SubprocessTimeoutError"
}
}
export type RunTaskOptions = {
run: Run
task: Task
jobToken: string | null
publish: (taskEvent: TaskEvent) => Promise<void>
logger: Logger
}

View file

@ -1,10 +1,15 @@
import * as fs from "fs"
import * as fsp from "fs/promises"
import * as path from "path"
import { execa } from "execa"
import { execa, type ResultPromise } from "execa"
import type { ToolUsage } from "@roo-code/types"
import type { Run, Task } from "../db/index.js"
import { SubprocessTimeoutError } from "./types.js"
export const getTag = (caller: string, { run, task }: { run: Run; task?: Task }) =>
task
? `${caller} | pid:${process.pid} | run:${run.id} | task:${task.id} | ${task.language}/${task.exercise}`
@ -107,6 +112,22 @@ export class Logger {
this.info(message, ...args)
}
/**
* Write raw output without any prefix (timestamp, level, tag).
* Useful for streaming CLI output where the prefix would be noise.
*/
public raw(message: string): void {
try {
console.log(message)
if (this.logStream) {
this.logStream.write(message + "\n")
}
} catch (error) {
console.error(`Failed to write to log file ${this.logFilePath}:`, error)
}
}
public close(): void {
if (this.logStream) {
this.logStream.end()
@ -114,3 +135,117 @@ export class Logger {
}
}
}
/**
* Copy conversation history files from VS Code extension storage to the log directory.
* This allows us to preserve the api_conversation_history.json and ui_messages.json
* files for post-mortem analysis alongside the log files.
*/
export async function copyConversationHistory({
rooTaskId,
logDir,
language,
exercise,
iteration,
logger,
}: {
rooTaskId: string
logDir: string
language: string
exercise: string
iteration: number
logger: Logger
}): Promise<void> {
// VS Code extension global storage path within the container
const extensionStoragePath = "/roo/.vscode/User/globalStorage/rooveterinaryinc.roo-cline"
const taskStoragePath = path.join(extensionStoragePath, "tasks", rooTaskId)
const filesToCopy = ["api_conversation_history.json", "ui_messages.json"]
for (const filename of filesToCopy) {
const sourcePath = path.join(taskStoragePath, filename)
// Use sanitized exercise name (replace slashes with dashes) for the destination filename
// Include iteration number to handle multiple attempts at the same exercise
const sanitizedExercise = exercise.replace(/\//g, "-")
const destFilename = `${language}-${sanitizedExercise}.${iteration}_${filename}`
const destPath = path.join(logDir, destFilename)
try {
// Check if source file exists
await fsp.access(sourcePath)
// Copy the file
await fsp.copyFile(sourcePath, destPath)
logger.info(`copied ${filename} to ${destPath}`)
} catch (error) {
// File may not exist if task didn't complete properly - this is not fatal
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
logger.info(`${filename} not found at ${sourcePath} - skipping`)
} else {
logger.error(`failed to copy ${filename}:`, error)
}
}
}
}
/**
* Merge incoming tool usage with accumulated data using MAX strategy.
* This handles the case where a task is rehydrated after abort:
* - Empty rehydrated data won't overwrite existing: max(5, 0) = 5
* - Legitimate restart with additional work is captured: max(5, 8) = 8
* Each task instance tracks its own cumulative values, so we take the max
* to preserve the highest values seen across all instances.
*/
export function mergeToolUsage(accumulated: ToolUsage, incoming: ToolUsage): void {
for (const [toolName, usage] of Object.entries(incoming)) {
const existing = accumulated[toolName as keyof ToolUsage]
if (existing) {
accumulated[toolName as keyof ToolUsage] = {
attempts: Math.max(existing.attempts, usage.attempts),
failures: Math.max(existing.failures, usage.failures),
}
} else {
accumulated[toolName as keyof ToolUsage] = { ...usage }
}
}
}
/**
* Wait for a subprocess to finish gracefully, with a timeout.
* If the subprocess doesn't finish within the timeout, force kill it with SIGKILL.
*/
export async function waitForSubprocessWithTimeout({
subprocess,
timeoutMs = 10_000,
logger,
}: {
subprocess: ResultPromise
timeoutMs?: number
logger: Logger
}): Promise<void> {
try {
await Promise.race([
subprocess,
new Promise((_, reject) => setTimeout(() => reject(new SubprocessTimeoutError(timeoutMs)), timeoutMs)),
])
logger.info("subprocess finished gracefully")
} catch (error) {
if (error instanceof SubprocessTimeoutError) {
logger.error("subprocess did not finish within timeout, force killing")
try {
if (subprocess.kill("SIGKILL")) {
logger.info("SIGKILL sent to subprocess")
} else {
logger.error("failed to send SIGKILL to subprocess")
}
} catch (killError) {
logger.error("subprocess.kill(SIGKILL) failed:", killError)
}
} else {
throw error
}
}
}

View file

@ -0,0 +1 @@
ALTER TABLE "runs" ADD COLUMN "execution_method" text DEFAULT 'vscode' NOT NULL;

View file

@ -0,0 +1,479 @@
{
"id": "ae1ebc36-8f5b-43e1-8e47-5a63d72ed05f",
"prevId": "71b54967-86df-42ec-a200-bfd8dad85069",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.runs": {
"name": "runs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "runs_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"task_metrics_id": {
"name": "task_metrics_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"contextWindow": {
"name": "contextWindow",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"inputPrice": {
"name": "inputPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"outputPrice": {
"name": "outputPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"cacheWritesPrice": {
"name": "cacheWritesPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"cacheReadsPrice": {
"name": "cacheReadsPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"jobToken": {
"name": "jobToken",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pid": {
"name": "pid",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"socket_path": {
"name": "socket_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"execution_method": {
"name": "execution_method",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'vscode'"
},
"concurrency": {
"name": "concurrency",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 2
},
"timeout": {
"name": "timeout",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 5
},
"passed": {
"name": "passed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"failed": {
"name": "failed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"runs_task_metrics_id_taskMetrics_id_fk": {
"name": "runs_task_metrics_id_taskMetrics_id_fk",
"tableFrom": "runs",
"tableTo": "taskMetrics",
"columnsFrom": ["task_metrics_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.taskMetrics": {
"name": "taskMetrics",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "taskMetrics_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"tokens_in": {
"name": "tokens_in",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tokens_out": {
"name": "tokens_out",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tokens_context": {
"name": "tokens_context",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cache_writes": {
"name": "cache_writes",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cache_reads": {
"name": "cache_reads",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cost": {
"name": "cost",
"type": "real",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tool_usage": {
"name": "tool_usage",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tasks": {
"name": "tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tasks_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"run_id": {
"name": "run_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"task_metrics_id": {
"name": "task_metrics_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true
},
"exercise": {
"name": "exercise",
"type": "text",
"primaryKey": false,
"notNull": true
},
"iteration": {
"name": "iteration",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"passed": {
"name": "passed",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"finished_at": {
"name": "finished_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"tasks_language_exercise_iteration_idx": {
"name": "tasks_language_exercise_iteration_idx",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "language",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "exercise",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "iteration",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tasks_run_id_runs_id_fk": {
"name": "tasks_run_id_runs_id_fk",
"tableFrom": "tasks",
"tableTo": "runs",
"columnsFrom": ["run_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"tasks_task_metrics_id_taskMetrics_id_fk": {
"name": "tasks_task_metrics_id_taskMetrics_id_fk",
"tableFrom": "tasks",
"tableTo": "taskMetrics",
"columnsFrom": ["task_metrics_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.toolErrors": {
"name": "toolErrors",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "toolErrors_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"run_id": {
"name": "run_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"task_id": {
"name": "task_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"toolErrors_run_id_runs_id_fk": {
"name": "toolErrors_run_id_runs_id_fk",
"tableFrom": "toolErrors",
"tableTo": "runs",
"columnsFrom": ["run_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"toolErrors_task_id_tasks_id_fk": {
"name": "toolErrors_task_id_tasks_id_fk",
"tableFrom": "toolErrors",
"tableTo": "tasks",
"columnsFrom": ["task_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -43,6 +43,13 @@
"when": 1765167049182,
"tag": "0005_strong_skrulls",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767550126096,
"tag": "0006_worried_spectrum",
"breakpoints": true
}
]
}

View file

@ -5,6 +5,12 @@ import type { RooCodeSettings, ToolName, ToolUsage } from "@roo-code/types"
import type { ExerciseLanguage } from "../exercises/index.js"
/**
* ExecutionMethod
*/
export type ExecutionMethod = "vscode" | "cli"
/**
* runs
*/
@ -24,6 +30,7 @@ export const runs = pgTable("runs", {
jobToken: text(),
pid: integer(),
socketPath: text("socket_path").notNull(),
executionMethod: text("execution_method").default("vscode").notNull().$type<ExecutionMethod>(),
concurrency: integer().default(2).notNull(),
timeout: integer().default(5).notNull(),
passed: integer().default(0).notNull(),

View file

@ -1,6 +1,6 @@
{
"name": "@roo-code/types",
"version": "1.93.0",
"version": "1.99.0",
"description": "TypeScript type definitions for Roo Code.",
"publishConfig": {
"access": "public",

View file

@ -0,0 +1,89 @@
import {
type CustomToolDefinition,
type CustomToolContext,
defineCustomTool,
parametersSchema as z,
} from "../custom-tool.js"
import type { TaskLike } from "../task.js"
describe("custom-tool utilities", () => {
describe("z (Zod re-export)", () => {
it("should export z from zod", () => {
expect(z).toBeDefined()
expect(z.string).toBeInstanceOf(Function)
expect(z.object).toBeInstanceOf(Function)
expect(z.number).toBeInstanceOf(Function)
})
it("should allow creating schemas", () => {
const schema = z.object({
name: z.string(),
count: z.number().optional(),
})
const result = schema.parse({ name: "test" })
expect(result).toEqual({ name: "test" })
})
})
describe("defineCustomTool", () => {
it("should return the same definition object", () => {
const definition = {
name: "test-tool",
description: "Test tool",
parameters: z.object({ input: z.string() }),
execute: async (args: { input: string }) => `Result: ${args.input}`,
}
const result = defineCustomTool(definition)
expect(result).toBe(definition)
})
it("should work without parameters", () => {
const tool = defineCustomTool({
name: "no-params-tool",
description: "No params tool",
execute: async () => "done",
})
expect(tool.description).toBe("No params tool")
expect(tool.parameters).toBeUndefined()
})
it("should preserve type inference for execute args", async () => {
const tool = defineCustomTool({
name: "typed-tool",
description: "Typed tool",
parameters: z.object({
name: z.string(),
count: z.number(),
}),
execute: async (args) => {
// TypeScript should infer args as { name: string, count: number }.
return `Hello ${args.name}, count is ${args.count}`
},
})
const context: CustomToolContext = {
mode: "code",
task: { taskId: "test-task-id" } as unknown as TaskLike,
}
const result = await tool.execute({ name: "World", count: 42 }, context)
expect(result).toBe("Hello World, count is 42")
})
})
describe("CustomToolDefinition type", () => {
it("should accept valid definitions", () => {
const def: CustomToolDefinition = {
name: "valid-tool",
description: "A valid tool",
parameters: z.object({}),
execute: async () => "result",
}
expect(def.description).toBe("A valid tool")
})
})
})

View file

@ -0,0 +1,104 @@
import type { ZodType, z } from "zod/v4"
import { TaskLike } from "./task.js"
// Re-export from Zod for convenience.
export { z as parametersSchema } from "zod/v4"
export type CustomToolParametersSchema = ZodType
export type SerializedCustomToolParameters = z.core.JSONSchema.JSONSchema
/**
* Context provided to tool execute functions.
*/
export interface CustomToolContext {
mode: string
task: TaskLike
}
/**
* Definition structure for a custom tool.
*
* Note: This interface uses simple types to avoid TypeScript performance issues
* with Zod's complex type inference. For type-safe parameter inference, use
* the `defineCustomTool` helper function instead of annotating with this interface.
*/
export interface CustomToolDefinition {
/**
* The name of the tool.
* This is used to identify the tool in the prompt and in the tool registry.
*/
name: string
/**
* A description of what the tool does.
* This is shown to the AI model to help it decide when to use the tool.
*/
description: string
/**
* Optional Zod schema defining the tool's parameters.
* Use `z.object({})` to define the shape of arguments.
*/
parameters?: CustomToolParametersSchema
/**
* The function that executes the tool.
*
* @param args - The validated arguments
* @param context - Execution context with session and message info
* @returns A string result to return to the AI
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute: (args: any, context: CustomToolContext) => Promise<string>
}
export interface SerializedCustomToolDefinition {
name: string
description: string
parameters?: SerializedCustomToolParameters
source?: string
}
/**
* Type-safe definition structure for a custom tool with inferred parameter types.
* Use this with `defineCustomTool` for full type inference.
*
* @template T - The Zod schema type for parameters
*/
export interface TypedCustomToolDefinition<T extends CustomToolParametersSchema>
extends Omit<CustomToolDefinition, "execute" | "parameters"> {
parameters?: T
execute: (args: z.infer<T>, context: CustomToolContext) => Promise<string>
}
/**
* Helper function to define a custom tool with proper type inference.
*
* This is optional - you can also just export a plain object that matches
* the CustomToolDefinition interface.
*
* @example
* ```ts
* import { parametersSchema as z, defineCustomTool } from "@roo-code/types"
*
* export default defineCustomTool({
* name: "add_numbers",
* description: "Add two numbers",
* parameters: z.object({
* a: z.number().describe("First number"),
* b: z.number().describe("Second number"),
* }),
* async execute({ a, b }) {
* return `The sum is ${a + b}`
* }
* })
* ```
*/
export function defineCustomTool<T extends CustomToolParametersSchema>(
definition: TypedCustomToolDefinition<T>,
): TypedCustomToolDefinition<T> {
return definition
}

View file

@ -13,6 +13,7 @@ export const experimentIds = [
"imageGeneration",
"runSlashCommand",
"multipleNativeToolCalls",
"customTools",
] as const
export const experimentIdsSchema = z.enum(experimentIds)
@ -30,6 +31,7 @@ export const experimentsSchema = z.object({
imageGeneration: z.boolean().optional(),
runSlashCommand: z.boolean().optional(),
multipleNativeToolCalls: z.boolean().optional(),
customTools: z.boolean().optional(),
})
export type Experiments = z.infer<typeof experimentsSchema>

View file

@ -143,6 +143,7 @@ export const globalSettingsSchema = z.object({
maxOpenTabsContext: z.number().optional(),
maxWorkspaceFiles: z.number().optional(),
showRooIgnoredFiles: z.boolean().optional(),
enableSubfolderRules: z.boolean().optional(),
maxReadFileLine: z.number().optional(),
maxImageFileSize: z.number().optional(),
maxTotalImageSize: z.number().optional(),
@ -291,7 +292,6 @@ export const isGlobalStateKey = (key: string): key is Keys<GlobalState> =>
// Default settings when running evals (unless overridden).
export const EVALS_SETTINGS: RooCodeSettings = {
apiProvider: "openrouter",
openRouterUseMiddleOutTransform: false,
lastShownAnnouncementId: "jul-09-2025-3-23-0",

View file

@ -3,6 +3,7 @@ export * from "./cloud.js"
export * from "./codebase-index.js"
export * from "./context-management.js"
export * from "./cookie-consent.js"
export * from "./custom-tool.js"
export * from "./events.js"
export * from "./experiment.js"
export * from "./followup.js"
@ -16,7 +17,6 @@ export * from "./message.js"
export * from "./mode.js"
export * from "./model.js"
export * from "./provider-settings.js"
export * from "./single-file-read-models.js"
export * from "./task.js"
export * from "./todo.js"
export * from "./telemetry.js"

View file

@ -129,6 +129,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk {
* - `api_req_finished`: Indicates an API request has completed successfully
* - `api_req_retried`: Indicates an API request is being retried after a failure
* - `api_req_retry_delayed`: Indicates an API request retry has been delayed
* - `api_req_rate_limit_wait`: Indicates a configured rate-limit wait (not an error)
* - `api_req_deleted`: Indicates an API request has been deleted/cancelled
* - `text`: General text message or assistant response
* - `reasoning`: Assistant's reasoning or thought process (often hidden from user)
@ -155,6 +156,7 @@ export const clineSays = [
"api_req_finished",
"api_req_retried",
"api_req_retry_delayed",
"api_req_rate_limit_wait",
"api_req_deleted",
"text",
"image",

Some files were not shown because too many files have changed in this diff Show more