diff --git a/.gemini/commands/promote-behavioral-eval.toml b/.gemini/commands/promote-behavioral-eval.toml new file mode 100644 index 0000000000..9893e9b02b --- /dev/null +++ b/.gemini/commands/promote-behavioral-eval.toml @@ -0,0 +1,29 @@ +description = "Promote behavioral evals that have a 100% success rate over the last 7 nightly runs." +prompt = """ +You are an expert at analyzing and promoting behavioral evaluations. + +1. **Investigate**: + - Use 'gh' cli to fetch the results from the most recent run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml. + - DO NOT push any changes or start any runs. The rest of your evaluation will be local. + - Evals are in evals/ directory and are documented by evals/README.md. + - Identify tests that have passed 100% of the time for ALL enabled models across the past 7 runs in a row. + - NOTE: the results summary from the most recent run contains the last 7 runs test results. 100% means the test passed 3/3 times for that model and run. + - If a test meets this criteria, it is a candidate for promotion. + +2. **Promote**: + - For each candidate test, locate the test file in the evals/ directory. + - Promote the test according to the project's standard promotion process (e.g., moving it to a stable suite, updating its tags, or removing skip/flaky annotations). + - Ensure you follow any guidelines in evals/README.md for stable tests. + - Your **final** change should be **minimal and targeted** to just promoting the test status. + +3. **Verify**: + - Run the promoted tests locally to validate that they still execute correctly. Be sure to run vitest in non-interactive mode. + - Check that the test is now part of the expected standard or stable test suites. + +4. **Report**: + - Provide a summary of the tests that were promoted. + - Include the success rate evidence (7/7 runs passed for all models) for each promoted test. + - If no tests met the criteria for promotion, clearly state that and summarize the closest candidates. + +{{args}} +""" diff --git a/.gemini/commands/strict-development-rules.md b/.gemini/commands/strict-development-rules.md index 9c01860091..6620c024ae 100644 --- a/.gemini/commands/strict-development-rules.md +++ b/.gemini/commands/strict-development-rules.md @@ -107,7 +107,7 @@ Gemini CLI project. set. - **Logging**: Use `debugLogger` for rethrown errors to avoid duplicate logging. - **Keyboard Shortcuts**: Define all new keyboard shortcuts in - `packages/cli/src/config/keyBindings.ts` and document them in + `packages/cli/src/ui/key/keyBindings.ts` and document them in `docs/cli/keyboard-shortcuts.md`. Be careful of keybindings that require the `Meta` key, as only certain meta key shortcuts are supported on Mac. Avoid function keys and shortcuts commonly bound in VSCode. diff --git a/.gemini/config.yaml b/.gemini/config.yaml index cbfb0c8059..b9543f0b7d 100644 --- a/.gemini/config.yaml +++ b/.gemini/config.yaml @@ -9,4 +9,5 @@ code_review: help: false summary: true code_review: true + include_drafts: false ignore_patterns: [] diff --git a/.gemini/settings.json b/.gemini/settings.json index 1a4c889066..9051dc78de 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -2,7 +2,8 @@ "experimental": { "plan": true, "extensionReloading": true, - "modelSteering": true + "modelSteering": true, + "memoryManager": true }, "general": { "devtools": true diff --git a/.gemini/skills/async-pr-review/SKILL.md b/.gemini/skills/async-pr-review/SKILL.md new file mode 100644 index 0000000000..74bc469b56 --- /dev/null +++ b/.gemini/skills/async-pr-review/SKILL.md @@ -0,0 +1,45 @@ +--- +name: async-pr-review +description: Trigger this skill when the user wants to start an asynchronous PR review, run background checks on a PR, or check the status of a previously started async PR review. +--- + +# Async PR Review + +This skill provides a set of tools to asynchronously review a Pull Request. It will create a background job to run the project's preflight checks, execute Gemini-powered test plans, and perform a comprehensive code review using custom prompts. + +This skill is designed to showcase an advanced "Agentic Asynchronous Pattern": +1. **Native Background Shells vs Headless Inference**: While Gemini CLI can natively spawn and detach background shell commands (using the `run_shell_command` tool with `is_background: true`), a standard bash background job cannot perform LLM inference. To conduct AI-driven code reviews and test generation in the background, the shell script *must* invoke the `gemini` executable headlessly using `-p`. This offloads the AI tasks to independent worker agents. +2. **Dynamic Git Scoping**: The review scripts avoid hardcoded paths. They use `git rev-parse --show-toplevel` to automatically resolve the root of the user's current project. +3. **Ephemeral Worktrees**: Instead of checking out branches in the user's main workspace, the skill provisions temporary git worktrees in `.gemini/tmp/async-reviews/pr-`. This prevents git lock conflicts and namespace pollution. +4. **Agentic Evaluation (`check-async-review.sh`)**: The check script outputs clean JSON/text statuses for the main agent to parse. The interactive agent itself synthesizes the final assessment dynamically from the generated log files. + +## Workflow + +1. **Determine Action**: Establish whether the user wants to start a new async review or check the status of an existing one. + * If the user says "start an async review for PR #123" or similar, proceed to **Start Review**. + * If the user says "check the status of my async review for PR #123" or similar, proceed to **Check Status**. + +### Start Review + +If the user wants to start a new async PR review: + +1. Ask the user for the PR number if they haven't provided it. +2. Execute the `async-review.sh` script, passing the PR number as the first argument. Be sure to run it with the `is_background` flag set to true to ensure it immediately detaches. + ```bash + .gemini/skills/async-pr-review/scripts/async-review.sh + ``` +3. Inform the user that the tasks have started successfully and they can check the status later. + +### Check Status + +If the user wants to check the status or view the final assessment of a previously started async review: + +1. Ask the user for the PR number if they haven't provided it. +2. Execute the `check-async-review.sh` script, passing the PR number as the first argument: + ```bash + .gemini/skills/async-pr-review/scripts/check-async-review.sh + ``` +3. **Evaluate Output**: Read the output from the script. + * If the output contains `STATUS: IN_PROGRESS`, tell the user which tasks are still running. + * If the output contains `STATUS: COMPLETE`, use your file reading tools (`read_file`) to retrieve the contents of `final-assessment.md`, `review.md`, `pr-diff.diff`, `npm-test.log`, and `test-execution.log` files from the `LOG_DIR` specified in the output. + * **Final Assessment**: Read those files, synthesize their results, and give the user a concise recommendation on whether the PR builds successfully, passes tests, and if you recommend they approve it based on the review. \ No newline at end of file diff --git a/.gemini/skills/async-pr-review/policy.toml b/.gemini/skills/async-pr-review/policy.toml new file mode 100644 index 0000000000..dd26fd772c --- /dev/null +++ b/.gemini/skills/async-pr-review/policy.toml @@ -0,0 +1,148 @@ +# --- CORE TOOLS --- +[[rule]] +toolName = "read_file" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "write_file" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "grep_search" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "glob" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "list_directory" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "codebase_investigator" +decision = "allow" +priority = 100 + +# --- SHELL COMMANDS --- + +# Git (Safe/Read-only) +[[rule]] +toolName = "run_shell_command" +commandPrefix = [ + "git blame", + "git show", + "git grep", + "git show-ref", + "git ls-tree", + "git ls-remote", + "git reflog", + "git remote -v", + "git diff", + "git rev-list", + "git rev-parse", + "git merge-base", + "git cherry", + "git fetch", + "git status", + "git st", + "git branch", + "git br", + "git log", + "git --version" +] +decision = "allow" +priority = 100 + +# GitHub CLI (Read-only) +[[rule]] +toolName = "run_shell_command" +commandPrefix = [ + "gh workflow list", + "gh auth status", + "gh checkout view", + "gh run view", + "gh run job view", + "gh run list", + "gh run --help", + "gh issue view", + "gh issue list", + "gh label list", + "gh pr diff", + "gh pr check", + "gh pr checks", + "gh pr view", + "gh pr list", + "gh pr status", + "gh repo view", + "gh job view", + "gh api", + "gh log" +] +decision = "allow" +priority = 100 + +# Node.js/NPM (Generic Tests, Checks, and Build) +[[rule]] +toolName = "run_shell_command" +commandPrefix = [ + "npm run start", + "npm install", + "npm run", + "npm test", + "npm ci", + "npm list", + "npm --version" +] +decision = "allow" +priority = 100 + +# Core Utilities (Safe) +[[rule]] +toolName = "run_shell_command" +commandPrefix = [ + "sleep", + "env", + "break", + "xargs", + "base64", + "uniq", + "sort", + "echo", + "which", + "ls", + "find", + "tail", + "head", + "cat", + "cd", + "grep", + "ps", + "pwd", + "wc", + "file", + "stat", + "diff", + "lsof", + "date", + "whoami", + "uname", + "du", + "cut", + "true", + "false", + "readlink", + "awk", + "jq", + "rg", + "less", + "more", + "tree" +] +decision = "allow" +priority = 100 diff --git a/.gemini/skills/async-pr-review/scripts/async-review.sh b/.gemini/skills/async-pr-review/scripts/async-review.sh new file mode 100755 index 0000000000..d408c5f2f1 --- /dev/null +++ b/.gemini/skills/async-pr-review/scripts/async-review.sh @@ -0,0 +1,241 @@ +#!/bin/bash + +notify() { + local title="$1" + local message="$2" + local pr="$3" + # Terminal escape sequence + printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message" + # Native macOS notification + if [[ "$(uname)" == "Darwin" ]]; then + osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\"" + fi +} + +pr_number=$1 +if [[ -z "$pr_number" ]]; then + echo "Usage: async-review " + exit 1 +fi + +base_dir=$(git rev-parse --show-toplevel 2>/dev/null) +if [[ -z "$base_dir" ]]; then + echo "❌ Must be run from within a git repository." + exit 1 +fi + +# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs +pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number" +target_dir="$pr_dir/worktree" +log_dir="$pr_dir/logs" + +cd "$base_dir" || exit 1 + +mkdir -p "$log_dir" +rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md" + +echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log" +git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true +git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true +git worktree prune >> "$log_dir/setup.log" 2>&1 || true + +echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log" +if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then + echo 1 > "$log_dir/setup.exit" + echo "❌ Fetch failed. Check $log_dir/setup.log" + notify "Async Review Failed" "Fetch failed." "$pr_number" + exit 1 +fi + +if [[ ! -d "$target_dir" ]]; then + echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log" + git worktree prune >> "$log_dir/setup.log" 2>&1 + echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log" + if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then + echo 1 > "$log_dir/setup.exit" + echo "❌ Worktree creation failed. Check $log_dir/setup.log" + notify "Async Review Failed" "Worktree creation failed." "$pr_number" + exit 1 + fi +else + echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log" +fi +echo 0 > "$log_dir/setup.exit" + +cd "$target_dir" || exit 1 + +echo "🚀 Launching background tasks. Logs saving to: $log_dir" + +echo " ↳ [1/5] Grabbing PR diff..." +rm -f "$log_dir/pr-diff.exit" +{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } & + +echo " ↳ [2/5] Starting build and lint..." +rm -f "$log_dir/build-and-lint.exit" +{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } & + +# Dynamically resolve gemini binary (fallback to your nightly path) +GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini") +POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml" + +echo " ↳ [3/5] Starting Gemini code review..." +rm -f "$log_dir/review.exit" +{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } & + +echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..." +rm -f "$log_dir/npm-test.exit" +{ + while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done + if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then + gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1 + ci_status=$? + + if [ "$ci_status" -eq 0 ]; then + echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log" + echo 0 > "$log_dir/npm-test.exit" + elif [ "$ci_status" -eq 8 ]; then + echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log" + echo 0 > "$log_dir/npm-test.exit" + else + echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log" + gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1 + + echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log" + pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null) + run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null) + + failed_files="" + if [[ -n "$run_id" ]]; then + failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq) + fi + + if [[ -n "$failed_files" ]]; then + echo "Found failing test files from CI:" >> "$log_dir/npm-test.log" + for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done + echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log" + + exit_code=0 + for file in $failed_files; do + if [[ "$file" == packages/* ]]; then + ws_dir=$(echo "$file" | cut -d'/' -f1,2) + else + ws_dir=$(echo "$file" | cut -d'/' -f1) + fi + rel_file=${file#$ws_dir/} + + echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log" + if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then + exit_code=1 + fi + done + echo $exit_code > "$log_dir/npm-test.exit" + else + echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log" + echo 1 > "$log_dir/npm-test.exit" + fi + fi + else + echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log" + echo 1 > "$log_dir/npm-test.exit" + fi +} & + +echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..." +rm -f "$log_dir/test-execution.exit" +{ + while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done + if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then + "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit" + else + echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log" + echo 1 > "$log_dir/test-execution.exit" + fi +} & + +echo "✅ All tasks dispatched!" +echo "You can monitor progress with: tail -f $log_dir/*.log" +echo "Read your review later at: $log_dir/review.md" + +# Polling loop to wait for all background tasks to finish +tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution") +log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log") + +declare -A task_done +for t in "${tasks[@]}"; do task_done[$t]=0; done + +all_done=0 +while [[ $all_done -eq 0 ]]; do + clear + echo "==================================================" + echo "🚀 Async PR Review Status for PR #$pr_number" + echo "==================================================" + echo "" + + all_done=1 + for i in "${!tasks[@]}"; do + t="${tasks[$i]}" + + if [[ -f "$log_dir/$t.exit" ]]; then + exit_code=$(cat "$log_dir/$t.exit") + if [[ "$exit_code" == "0" ]]; then + echo " ✅ $t: SUCCESS" + else + echo " ❌ $t: FAILED (exit code $exit_code)" + fi + task_done[$t]=1 + else + echo " ⏳ $t: RUNNING" + all_done=0 + fi + done + + echo "" + echo "==================================================" + echo "📝 Live Logs (Last 5 lines of running tasks)" + echo "==================================================" + + for i in "${!tasks[@]}"; do + t="${tasks[$i]}" + log_file="${log_files[$i]}" + + if [[ ${task_done[$t]} -eq 0 ]]; then + if [[ -f "$log_dir/$log_file" ]]; then + echo "" + echo "--- $t ---" + tail -n 5 "$log_dir/$log_file" + fi + fi + done + + if [[ $all_done -eq 0 ]]; then + sleep 3 + fi +done + +clear +echo "==================================================" +echo "🚀 Async PR Review Status for PR #$pr_number" +echo "==================================================" +echo "" +for t in "${tasks[@]}"; do + exit_code=$(cat "$log_dir/$t.exit") + if [[ "$exit_code" == "0" ]]; then + echo " ✅ $t: SUCCESS" + else + echo " ❌ $t: FAILED (exit code $exit_code)" + fi +done +echo "" + +echo "⏳ Tasks complete! Synthesizing final assessment..." +if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then + echo $? > "$log_dir/final-assessment.exit" + echo "❌ Final assessment synthesis failed!" + echo "Check $log_dir/final-assessment.md for details." + notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number" + exit 1 +fi + +echo 0 > "$log_dir/final-assessment.exit" +echo "✅ Final assessment complete! Check $log_dir/final-assessment.md" +notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number" diff --git a/.gemini/skills/async-pr-review/scripts/check-async-review.sh b/.gemini/skills/async-pr-review/scripts/check-async-review.sh new file mode 100755 index 0000000000..fbb58c2b72 --- /dev/null +++ b/.gemini/skills/async-pr-review/scripts/check-async-review.sh @@ -0,0 +1,65 @@ +#!/bin/bash +pr_number=$1 + +if [[ -z "$pr_number" ]]; then + echo "Usage: check-async-review " + exit 1 +fi + +base_dir=$(git rev-parse --show-toplevel 2>/dev/null) +if [[ -z "$base_dir" ]]; then + echo "❌ Must be run from within a git repository." + exit 1 +fi + +log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs" + +if [[ ! -d "$log_dir" ]]; then + echo "STATUS: NOT_FOUND" + echo "❌ No logs found for PR #$pr_number in $log_dir" + exit 0 +fi + +tasks=( + "setup|setup.log" + "pr-diff|pr-diff.diff" + "build-and-lint|build-and-lint.log" + "review|review.md" + "npm-test|npm-test.log" + "test-execution|test-execution.log" + "final-assessment|final-assessment.md" +) + +all_done=true +echo "STATUS: CHECKING" + +for task_info in "${tasks[@]}"; do + IFS="|" read -r task_name log_file <<< "$task_info" + + file_path="$log_dir/$log_file" + exit_file="$log_dir/$task_name.exit" + + if [[ -f "$exit_file" ]]; then + exit_code=$(cat "$exit_file") + if [[ "$exit_code" == "0" ]]; then + echo "✅ $task_name: SUCCESS" + else + echo "❌ $task_name: FAILED (exit code $exit_code)" + echo " Last lines of $file_path:" + tail -n 3 "$file_path" | sed 's/^/ /' + fi + elif [[ -f "$file_path" ]]; then + echo "⏳ $task_name: RUNNING" + all_done=false + else + echo "➖ $task_name: NOT STARTED" + all_done=false + fi +done + +if $all_done; then + echo "STATUS: COMPLETE" + echo "LOG_DIR: $log_dir" +else + echo "STATUS: IN_PROGRESS" +fi \ No newline at end of file diff --git a/.gemini/skills/docs-writer/SKILL.md b/.gemini/skills/docs-writer/SKILL.md index 13fc91765e..6d9788a3b0 100644 --- a/.gemini/skills/docs-writer/SKILL.md +++ b/.gemini/skills/docs-writer/SKILL.md @@ -45,6 +45,10 @@ Write precisely to ensure your instructions are unambiguous. specific verbs. - **Examples:** Use meaningful names in examples; avoid placeholders like "foo" or "bar." +- **Quota and limit terminology:** For any content involving resource capacity + or using the word "quota" or "limit", strictly adhere to the guidelines in + the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for the + administrative bucket and "limit" for the numerical ceiling. ### Formatting and syntax Apply consistent formatting to make documentation visually organized and @@ -67,12 +71,44 @@ accessible. tables). - **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text for all images. +- **Details section:** Use the `
` tag to create a collapsible section. + This is useful for supplementary or data-heavy information that isn't critical + to the main flow. + + Example: + +
+ Title + + - First entry + - Second entry + +
+ +- **Callouts**: Use GitHub-flavored markdown alerts to highlight important + information. To ensure the formatting is preserved by `npm run format`, place + an empty line, then the `` comment directly before + the callout block. The callout type (`[!TYPE]`) should be on the first line, + followed by a newline, and then the content, with each subsequent line of + content starting with `>`. Available types are `NOTE`, `TIP`, `IMPORTANT`, + `WARNING`, and `CAUTION`. + + Example: + + +> [!NOTE] +> This is an example of a multi-line note that will be preserved +> by Prettier. ### Structure - **BLUF:** Start with an introduction explaining what to expect. - **Experimental features:** If a feature is clearly noted as experimental, -add the following note immediately after the introductory paragraph: - `> **Note:** This is a preview feature currently under active development.` + add the following note immediately after the introductory paragraph: + + +> [!NOTE] +> This is an experimental feature currently under active development. + - **Headings:** Use hierarchical headings to support the user journey. - **Procedures:** - Introduce lists of steps with a complete sentence. @@ -81,8 +117,7 @@ add the following note immediately after the introductory paragraph: - Put conditions before instructions (e.g., "On the Settings page, click..."). - Provide clear context for where the action takes place. - Indicate optional steps clearly (e.g., "Optional: ..."). -- **Elements:** Use bullet lists, tables, notes (`> **Note:**`), and warnings - (`> **Warning:**`). +- **Elements:** Use bullet lists, tables, details, and callouts. - **Avoid using a table of contents:** If a table of contents is present, remove it. - **Next steps:** Conclude with a "Next steps" section if applicable. @@ -114,6 +149,8 @@ documentation. reflects existing code. - **Structure:** Apply "Structure (New Docs)" rules (BLUF, headings, etc.) when adding new sections to existing pages. +- **Headers**: If you change a header, you must check for links that lead to + that header and update them. - **Tone:** Ensure the tone is active and engaging. Use "you" and contractions. - **Clarity:** Correct awkward wording, spelling, and grammar. Rephrase sentences to make them easier for users to understand. @@ -129,7 +166,8 @@ and that all links are functional. technical behavior. 2. **Self-review:** Re-read changes for formatting, correctness, and flow. 3. **Link check:** Verify all new and existing links leading to or from modified - pages. + pages. If you changed a header, ensure that any links that lead to it are + updated. 4. **Format:** Once all changes are complete, ask to execute `npm run format` to ensure consistent formatting across the project. If the user confirms, execute the command. diff --git a/.gemini/skills/docs-writer/quota-limit-style-guide.md b/.gemini/skills/docs-writer/quota-limit-style-guide.md new file mode 100644 index 0000000000..b26c160cb5 --- /dev/null +++ b/.gemini/skills/docs-writer/quota-limit-style-guide.md @@ -0,0 +1,61 @@ +# Style Guide: Quota vs. Limit + +This guide defines the usage of "quota," "limit," and related terms in +user-facing interfaces. + +## TL;DR + +- **`quota`**: The administrative "bucket." Use for settings, billing, and + requesting increases. (e.g., "Adjust your storage **quota**.") +- **`limit`**: The real-time numerical "ceiling." Use for error messages when a + user is blocked. (e.g., "You've reached your request **limit**.") +- **When blocked, combine them:** Explain the **limit** that was hit and the + **quota** that is the remedy. (e.g., "You've reached the request **limit** for + your developer **quota**.") +- **Related terms:** Use `usage` for consumption tracking, `restriction` for + fixed rules, and `reset` for when a limit refreshes. + +--- + +## Detailed Guidelines + +### Definitions + +- **Quota is the "what":** It identifies the category of resource being managed + (e.g., storage quota, GPU quota, request/prompt quota). +- **Limit is the "how much":** It defines the numerical boundary. + +Use **quota** when referring to the administrative concept or the request for +more. Use **limit** when discussing the specific point of exhaustion. + +### When to use "quota" + +Use this term for **account management, billing, and settings.** It describes +the entitlement the user has purchased or been assigned. + +**Examples:** + +- **Navigation label:** Quota and usage +- **Contextual help:** Your **usage quota** is managed by your organization. To + request an increase, contact your administrator. + +### When to use "limit" + +Use this term for **real-time feedback, notifications, and error messages.** It +identifies the specific wall the user just hit. + +**Examples:** + +- **Error message:** You’ve reached the 50-request-per-minute **limit**. +- **Inline warning:** Input exceeds the 32k token **limit**. + +### How to use both together + +When a user is blocked, combine both terms to explain the **event** (limit) and +the **remedy** (quota). + +**Example:** + +- **Heading:** Daily usage limit reached +- **Body:** You've reached the maximum daily capacity for your developer quota. + To continue working today, upgrade your quota. diff --git a/.gemini/skills/github-issue-creator/SKILL.md b/.gemini/skills/github-issue-creator/SKILL.md new file mode 100644 index 0000000000..53aa612607 --- /dev/null +++ b/.gemini/skills/github-issue-creator/SKILL.md @@ -0,0 +1,76 @@ +--- +name: github-issue-creator +description: + Use this skill when asked to create a GitHub issue. It handles different issue + types (bug, feature, etc.) using repository templates and ensures proper + labeling. +--- + +# GitHub Issue Creator + +This skill guides the creation of high-quality GitHub issues that adhere to the +repository's standards and use the appropriate templates. + +## Workflow + +Follow these steps to create a GitHub issue: + +1. **Identify Issue Type**: Determine if the request is a bug report, feature + request, or other category. + +2. **Locate Template**: Search for issue templates in + `.github/ISSUE_TEMPLATE/`. + - `bug_report.yml` + - `feature_request.yml` + - `website_issue.yml` + - If no relevant YAML template is found, look for `.md` templates in the same + directory. + +3. **Read Template**: Read the content of the identified template file to + understand the required fields. + +4. **Draft Content**: Draft the issue title and body/fields. + - If using a YAML template (form), prepare values for each `id` defined in + the template. + - If using a Markdown template, follow its structure exactly. + - **Default Label**: Always include the `🔒 maintainer only` label unless the + user explicitly requests otherwise. + +5. **Create Issue**: Use the `gh` CLI to create the issue. + - **CRITICAL:** To avoid shell escaping and formatting issues with + multi-line Markdown or complex text, ALWAYS write the description/body to + a temporary file first. + + **For Markdown Templates or Simple Body:** + ```bash + # 1. Write the drafted content to a temporary file + # 2. Create the issue using the --body-file flag + gh issue create --title "Succinct title" --body-file --label "🔒 maintainer only" + # 3. Remove the temporary file + rm + ``` + + **For YAML Templates (Forms):** + While `gh issue create` supports `--body-file`, YAML forms usually expect + key-value pairs via flags if you want to bypass the interactive prompt. + However, the most reliable non-interactive way to ensure formatting is + preserved for long text fields is to use the `--body` or `--body-file` if the + form has been converted to a standard body, OR to use the `--field` flags + for YAML forms. + + *Note: For the `gemini-cli` repository which uses YAML forms, you can often + submit the content as a single body if a specific field-based submission is + not required by the automation.* + +6. **Verify**: Confirm the issue was created successfully and provide the link + to the user. + +## Principles + +- **Clarity**: Titles should be descriptive and follow project conventions. +- **Defensive Formatting**: Always use temporary files with `--body-file` to + prevent newline and special character issues. +- **Maintainer Priority**: Default to internal/maintainer labels to keep the + backlog organized. +- **Completeness**: Provide all requested information (e.g., version info, + reproduction steps). diff --git a/.gemini/skills/string-reviewer/SKILL.md b/.gemini/skills/string-reviewer/SKILL.md new file mode 100644 index 0000000000..f37d83b4ad --- /dev/null +++ b/.gemini/skills/string-reviewer/SKILL.md @@ -0,0 +1,99 @@ +--- +name: string-reviewer +description: > + Use this skill when asked to review text and user-facing strings within the codebase. It ensures that these strings follow rules on clarity, + usefulness, brevity and style. +--- + +# String Reviewer + +## Instructions + +Act as a Senior UX Writer. Look for user-facing strings that are too long, +unclear, or inconsistent. This includes inline text, error messages, and other +user-facing text. + +Do NOT automatically change strings without user approval. You must only suggest +changes and do not attempt to rewrite them directly unless the user explicitly +asks you to do so. + +## Core voice principles + +The system prioritizes deterministic clarity over conversational fluff. We +provide telemetry, not etiquette, ensuring the user retains absolute agency.. + +1. **Deterministic clarity:** Distinguish between certain system/service states + (Cloud Billing, IAM, the System) and probabilistic AI analysis (Gemini). +2. **System transparency:** Replace "Loading..." with active technical telemetry + (e.g., Tracing stack traces...). Keep status updates under 5 words. +3. **Front-loaded actionability:** Always use the [Goal] + [Action] pattern. + Lead with intent so users can scan left-to-right. +4. **Agentic error recovery:** Every error must be a pivot point. Pair failures + with one-click recovery commands or suggested prompts. +5. **Contextual humility:** Reserve disclaimers and "be careful" warnings for P0 + (destructive/irreversible) tasks only. Stop warning-fatigue. + +## The writing checklist + +Use this checklist to audit UI strings and AI responses. + +### Identity and voice +- **Eliminate the "I":** Remove all first-person pronouns (I, me, my, mine). +- **Subject attribution:** Refer to the AI as Gemini and the infrastructure as + the - system or the CLI. +- **Active voice:** Ensure the subject (Gemini or the system) is clearly + performing the action. +- **Ownership rule:** Use the system for execution (doing) and Gemini for + analysis (thinking) + +### Structural scannability +- **The skip test:** Do the first 3 words describe the user’s intent? If not, + rewrite. +- **Goal-first sequence:** Use the template: [To Accomplish X] + [Do Y]. +- **The 5-word rule:** Keep status updates and loading states under 5 words. +- **Telemetry over etiquette:** Remove polite filler (Please wait, Thank you, + Certainly). Replace with raw data or progress indicators. +- **Micro-state cycles:** For tasks $> 3$ seconds, cycle through specific + sub-states (e.g., Parsing logs... ➔ Identifying patterns...) to show momentum. + + +### Technical accuracy and humility +- **Verb signal check:** Use deterministic verbs (is, will, must) for system + state/infrastructure. + - Use probabilistic verbs (suggests, appears, may, identifies) for AI output. +- **No 100% certainty:** Never attribute absolute certainty to model-generated + content. +- **Precision over fuzziness:** Use technical metrics (latency, tokens, compute) instead of "speed" or "cost." +- **Instructional warnings:** Every warning must include a specific corrective action (e.g., "Perform a dry-run first" or "Review line 42"). + +### Agentic error recovery +- **The one-step rule:** Pair every error message with exactly one immediate + path to a fix (command, link, or prompt). +- **Human-first:** Provide a human-readable explanation before machine error + codes (e.g., 404, 500). +- **Suggested prompts:** Offer specific text for the user to copy/click like + “Ask Gemini: 'Explain this port error.'” + +### Use consistent terminology + +Ensure all terminology aligns with the project [word +list](./references/word-list.md). + +If a string uses a term marked "do not use" or "use with caution," provide a +correction based on the preferred terms. + +## Ensure consistent style for settings + +If `packages/cli/src/config/settingsSchema.ts` is modified, confirm labels and +descriptions specifically follow the unique [Settings +guidelines](./references/settings.md). + +## Output format +When suggesting changes, always present your review using the following list +format. Do not provide suggestions outside of this list.. + +``` +1. **{Rationale/Principle Violated}** + - ❌ "{incorrect phrase}" + - ✅ `"{corrected phrase}"` +``` \ No newline at end of file diff --git a/.gemini/skills/string-reviewer/references/settings.md b/.gemini/skills/string-reviewer/references/settings.md new file mode 100644 index 0000000000..df054127a8 --- /dev/null +++ b/.gemini/skills/string-reviewer/references/settings.md @@ -0,0 +1,28 @@ +# Settings + +## Noun-First Labeling (Scannability) + +Labels must start with the subject of the setting, not the action. This allows +users to scan for the feature they want to change. + +- **Rule:** `[Noun]` `[Attribute/Action]` +- **Example:** `Show line numbers` becomes simply `Line numbers` + +## Positive Boolean Logic (Cognitive Ease) + +Eliminate "double negatives." Booleans should represent the presence of a +feature, not its absence. + +- **Rule:** Replace `Disable {feature}` or `Hide {Feature}` with + `{Feature} enabled` or simply `{Feature}`. +- **Example:** Change "Disable auto update" to "Auto update". +- **Implementation:** Invert the boolean value in your config loader so true + always equals `On` + +## Verb Stripping (Brevity) + +Remove redundant leading verbs like "Enable," "Use," "Display," or "Show" unless +they are part of a specific technical term. + +- **Rule**: If the label works without the verb, remove it +- **Example**: Change `Enable prompt completion` to `Prompt completion` diff --git a/.gemini/skills/string-reviewer/references/word-list.md b/.gemini/skills/string-reviewer/references/word-list.md new file mode 100644 index 0000000000..1bb04b9817 --- /dev/null +++ b/.gemini/skills/string-reviewer/references/word-list.md @@ -0,0 +1,61 @@ +## Terms + +### Preferred + +- Use **create** when a user is creating or setting up something. +- Use **allow** instead of **may** to indicate that permission has been granted + to perform some action. +- Use **canceled**, not **cancelled**. +- Use **configure** to refer to the process of changing the attributes of a + feature, even if that includes turning on or off the feature. +- Use **delete** when the action being performed is destructive. +- Use **enable** for binary operations that turn a feature or API on. Use "turn + on" and "turn off" instead of "enable" and "disable" for other situations. +- Use **key combination** to refer to pressing multiple keys simultaneously. +- Use **key sequence** to refer to pressing multiple keys separately in order. +- Use **modify** to refer to something that has changed vs obtaining the latest + version of something. +- Use **remove** when the action being performed takes an item out of a larger + whole, but doesn't destroy the item itself. +- Use **set up** as a verb. Use **setup** as a noun or adjective. +- Use **show**. In general, use paired with **hide**. +- Use **sign in**, **sign out** as a verb. Use **sign-in** or **sign-out** as a + noun or adjective. +- Use **update** when you mean to obtain the latest version of something. +- Use **want** instead of **like** or **would like**. + +#### Don't use + +- Don't use **etc.** It's redundant. To convey that a series is incomplete, + introduce it with "such as" instead. +- Don't use **hostname**, use "host name" instead. +- Don't use **in order to**. It's too formal. "Before you can" is usually better + in UI text. +- Don't use **one or more**. Specify the quantity where possible. Use "at least + one" when the quantity is 1+ but you can't be sure of the number. Likewise, + use "at least one" when the user must choose a quantity of 1+. +- Don't use the terms **log in**, **log on**, **login**, **logout** or **log + out**. +- Don't use **like** or **would you like**. Use **want** instead. Better yet, + rephrase so that it's not referring to the user's emotional state, but rather + what is required. + +#### Use with caution + +- Avoid using **leverage**, especially as a verb. "Leverage" is considered a + buzzword largely devoid of meaning apart from the simpler "use". +- Avoid using **once** as a synonym for "after". Typically, when "once" is used + in this way, it is followed by a verb in the perfect tense. +- Don't use **e.g.** Use "example", "such as", "like", or "for example". The + phrase is always followed by a comma. +- Don't use **i.e.** unless absolutely essential to make text fit. Use "that is" + instead. +- Use **disable** for binary operations that turn a feature or API off. Use + "turn on" and "turn off" instead of "enable" and "disable" for other + situations. For UI elements that are not available, use "dimmed" instead of + "disabled". +- Use **please** only when you're asking the user to do something inconvenient, + not just following the instructions in a typical flow. +- Use **really** sparingly in such constructions as "Do you really want to..." + Because of the weight it puts on the decision, it should be used to confirm + actions that the user is extremely unlikely to make. diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 0000000000..e40b6ba36e --- /dev/null +++ b/.geminiignore @@ -0,0 +1 @@ +packages/core/src/services/scripts/*.exe diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8377d34af0..0da8dd1a0b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,3 +14,9 @@ # Docs have a dedicated approver group in addition to maintainers /docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs +/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs + +# Prompt contents, tool definitions, and evals require reviews from prompt approvers +/packages/core/src/prompts/ @google-gemini/gemini-cli-prompt-approvers +/packages/core/src/tools/ @google-gemini/gemini-cli-prompt-approvers +/evals/ @google-gemini/gemini-cli-prompt-approvers diff --git a/.github/ISSUE_TEMPLATE/website_issue.yml b/.github/ISSUE_TEMPLATE/website_issue.yml index 02146381ab..d9b30e1127 100644 --- a/.github/ISSUE_TEMPLATE/website_issue.yml +++ b/.github/ISSUE_TEMPLATE/website_issue.yml @@ -1,7 +1,9 @@ name: 'Website issue' description: 'Report an issue with the Gemini CLI Website and Gemini CLI Extensions Gallery' +title: 'GeminiCLI.com Feedback: [ISSUE]' labels: - 'area/extensions' + - 'area/documentation' body: - type: 'markdown' attributes: diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index 8f062205cb..54c404c7c1 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -192,6 +192,13 @@ runs: INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' + - name: '📦 Prepare bundled CLI for npm release' + if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/' && inputs.npm-tag != 'latest'" + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + run: | + node ${{ github.workspace }}/scripts/prepare-npm-release.js + - name: 'Get CLI Token' uses: './.github/actions/npm-auth-token' id: 'cli-token' diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml index e2d1ac942c..bab85af453 100644 --- a/.github/actions/push-sandbox/action.yml +++ b/.github/actions/push-sandbox/action.yml @@ -44,6 +44,8 @@ runs: - name: 'npm build' shell: 'bash' run: 'npm run build' + - name: 'Set up QEMU' + uses: 'docker/setup-qemu-action@v3' - name: 'Set up Docker Buildx' uses: 'docker/setup-buildx-action@v3' - name: 'Log in to GitHub Container Registry' @@ -69,16 +71,19 @@ runs: env: INPUTS_GITHUB_REF_NAME: '${{ inputs.github-ref-name }}' INPUTS_GITHUB_SHA: '${{ inputs.github-sha }}' + # We build amd64 just so we can verify it. + # We build and push both amd64 and arm64 in the publish step. - name: 'build' id: 'docker_build' shell: 'bash' env: GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' GEMINI_SANDBOX: 'docker' + BUILD_SANDBOX_FLAGS: '--platform linux/amd64 --load' STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' run: |- npm run build:sandbox -- \ - --image google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG} \ + --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}" \ --output-file final_image_uri.txt echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT - name: 'verify' @@ -92,10 +97,14 @@ runs: - name: 'publish' shell: 'bash' if: "${{ inputs.dry-run != 'true' }}" - run: |- - docker push "${STEPS_DOCKER_BUILD_OUTPUTS_URI}" env: - STEPS_DOCKER_BUILD_OUTPUTS_URI: '${{ steps.docker_build.outputs.uri }}' + GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' + GEMINI_SANDBOX: 'docker' + BUILD_SANDBOX_FLAGS: '--platform linux/amd64,linux/arm64 --push' + STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' + run: |- + npm run build:sandbox -- \ + --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}" - name: 'Create issue on failure' if: |- ${{ failure() }} diff --git a/.github/scripts/sync-maintainer-labels.cjs b/.github/scripts/sync-maintainer-labels.cjs index 41a75e99fa..1ee4a3618a 100644 --- a/.github/scripts/sync-maintainer-labels.cjs +++ b/.github/scripts/sync-maintainer-labels.cjs @@ -347,6 +347,36 @@ async function run() { }); } } + + // Remove status/need-triage from maintainer-only issues since they + // don't need community triage. We always attempt removal rather than + // checking the (potentially stale) label snapshot, because the + // issue-opened-labeler workflow runs concurrently and may add the + // label after our snapshot was taken. + if (isDryRun) { + console.log( + `[DRY RUN] Would remove status/need-triage from ${issueKey}`, + ); + } else { + try { + await octokit.rest.issues.removeLabel({ + owner: issueInfo.owner, + repo: issueInfo.repo, + issue_number: issueInfo.number, + name: 'status/need-triage', + }); + console.log(`Removed status/need-triage from ${issueKey}`); + } catch (removeError) { + // 404 means the label wasn't present — that's fine. + if (removeError.status === 404) { + console.log( + `status/need-triage not present on ${issueKey}, skipping.`, + ); + } else { + throw removeError; + } + } + } } catch (error) { console.error(`Error processing label for ${issueKey}: ${error.message}`); } diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index f1023c3ac8..8d714b34b0 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -31,6 +31,7 @@ jobs: name: 'Merge Queue Skipper' permissions: 'read-all' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" outputs: skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}' steps: @@ -42,7 +43,7 @@ jobs: download_repo_name: runs-on: 'gemini-cli-ubuntu-16-core' - if: "${{github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'}}" + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run')" outputs: repo_name: '${{ steps.output-repo-name.outputs.repo_name }}' head_sha: '${{ steps.output-repo-name.outputs.head_sha }}' @@ -53,7 +54,7 @@ jobs: REPO_NAME: '${{ github.event.inputs.repo_name }}' run: | mkdir -p ./pr - echo '${REPO_NAME}' > ./pr/repo_name + echo "${REPO_NAME}" > ./pr/repo_name - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'repo_name' @@ -91,7 +92,7 @@ jobs: name: 'Parse run context' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'download_repo_name' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" outputs: repository: '${{ steps.set_context.outputs.REPO }}' sha: '${{ steps.set_context.outputs.SHA }}' @@ -111,11 +112,11 @@ jobs: permissions: 'write-all' needs: - 'parse_run_context' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" steps: - name: 'Set pending status' uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' @@ -131,7 +132,7 @@ jobs: - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') strategy: fail-fast: false matrix: @@ -184,7 +185,7 @@ jobs: - 'parse_run_context' runs-on: 'macos-latest' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 @@ -222,7 +223,7 @@ jobs: - 'merge_queue_skipper' - 'parse_run_context' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') runs-on: 'gemini-cli-windows-16-core' steps: - name: 'Checkout' @@ -263,6 +264,27 @@ jobs: run: 'npm run build' shell: 'pwsh' + - name: 'Ensure Chrome is available' + shell: 'pwsh' + run: | + $chromePaths = @( + "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe" + ) + $chromeExists = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $chromeExists) { + Write-Host 'Chrome not found, installing via Chocolatey...' + choco install googlechrome -y --no-progress --ignore-checksums + } + $installed = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($installed) { + Write-Host "Chrome found at: $installed" + & $installed --version + } else { + Write-Error 'Chrome installation failed' + exit 1 + } + - name: 'Run E2E tests' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' @@ -282,13 +304,14 @@ jobs: - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 with: ref: '${{ needs.parse_run_context.outputs.sha }}' repository: '${{ needs.parse_run_context.outputs.repository }}' + fetch-depth: 0 - name: 'Set up Node.js 20.x' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 @@ -301,7 +324,14 @@ jobs: - name: 'Build project' run: 'npm run build' + - name: 'Check if evals should run' + id: 'check_evals' + run: | + SHOULD_RUN=$(node scripts/changed_prompt.js) + echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" + - name: 'Run Evals (Required to pass)' + if: "${{ steps.check_evals.outputs.should_run == 'true' }}" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' run: 'npm run test:always_passing_evals' @@ -309,7 +339,7 @@ jobs: e2e: name: 'E2E' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') needs: - 'e2e_linux' - 'e2e_mac' @@ -337,14 +367,14 @@ jobs: set_workflow_status: runs-on: 'gemini-cli-ubuntu-16-core' permissions: 'write-all' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" needs: - 'parse_run_context' - 'e2e' steps: - name: 'Set workflow status' uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 999eb778c4..973d88f5f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: permissions: 'read-all' name: 'Merge Queue Skipper' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" outputs: skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}' steps: @@ -49,7 +50,7 @@ jobs: name: 'Lint' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" env: GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters' steps: @@ -116,6 +117,7 @@ jobs: link_checker: name: 'Link Checker' runs-on: 'ubuntu-latest' + if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 @@ -129,7 +131,7 @@ jobs: runs-on: 'gemini-cli-ubuntu-16-core' needs: - 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' @@ -167,7 +169,7 @@ jobs: npm run test:ci --workspace @google/gemini-cli else # Explicitly list non-cli packages to ensure they are sharded correctly - npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present + npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false npm run test:scripts fi @@ -216,7 +218,7 @@ jobs: runs-on: 'macos-latest' needs: - 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' @@ -311,7 +313,7 @@ jobs: name: 'CodeQL' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: actions: 'read' contents: 'read' @@ -334,7 +336,7 @@ jobs: bundle_size: name: 'Check Bundle Size' needs: 'merge_queue_skipper' - if: "${{github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'" runs-on: 'gemini-cli-ubuntu-16-core' permissions: contents: 'read' # For checkout @@ -359,7 +361,7 @@ jobs: name: 'Slow Test - Win - ${{ matrix.shard }}' runs-on: 'gemini-cli-windows-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" timeout-minutes: 60 strategy: matrix: @@ -451,7 +453,7 @@ jobs: ci: name: 'CI' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" needs: - 'lint' - 'link_checker' diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index a0eb51a7f4..98635dbda7 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -27,6 +27,7 @@ jobs: deflake_e2e_linux: name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" strategy: fail-fast: false matrix: @@ -77,6 +78,7 @@ jobs: deflake_e2e_mac: name: 'E2E Test (macOS)' runs-on: 'macos-latest' + if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 @@ -114,7 +116,7 @@ jobs: deflake_e2e_windows: name: 'Slow E2E - Win' runs-on: 'gemini-cli-windows-16-core' - + if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 diff --git a/.github/workflows/docs-page-action.yml b/.github/workflows/docs-page-action.yml index 2d485278ce..be807c7c36 100644 --- a/.github/workflows/docs-page-action.yml +++ b/.github/workflows/docs-page-action.yml @@ -19,8 +19,7 @@ concurrency: jobs: build: - if: |- - ${{ !contains(github.ref_name, 'nightly') }} + if: "github.repository == 'google-gemini/gemini-cli' && !contains(github.ref_name, 'nightly')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' @@ -39,6 +38,7 @@ jobs: uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3 deploy: + if: "github.repository == 'google-gemini/gemini-cli'" environment: name: 'github-pages' url: '${{ steps.deployment.outputs.page_url }}' diff --git a/.github/workflows/docs-rebuild.yml b/.github/workflows/docs-rebuild.yml index ac41819f02..a4e2c65973 100644 --- a/.github/workflows/docs-rebuild.yml +++ b/.github/workflows/docs-rebuild.yml @@ -7,6 +7,7 @@ on: - 'docs/**' jobs: trigger-rebuild: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' steps: - name: 'Trigger rebuild' diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml index 1ed9448c03..ee17a95121 100644 --- a/.github/workflows/evals-nightly.yml +++ b/.github/workflows/evals-nightly.yml @@ -23,6 +23,7 @@ jobs: evals: name: 'Evals (USUALLY_PASSING) nightly run' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" strategy: fail-fast: false matrix: @@ -60,6 +61,7 @@ jobs: GEMINI_MODEL: '${{ matrix.model }}' RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}" TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' + VITEST_RETRY: 0 run: | CMD="npm run test:all_evals" PATTERN="${TEST_NAME_PATTERN}" @@ -85,7 +87,7 @@ jobs: aggregate-results: name: 'Aggregate Results' needs: ['evals'] - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Checkout' diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index fe4c52292a..1cab2abaa9 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -121,6 +121,7 @@ jobs: 'area/security', 'area/platform', 'area/extensions', + 'area/documentation', 'area/unknown' ]; const labelNames = labels.map(label => label.name).filter(name => allowedLabels.includes(name)); @@ -255,6 +256,14 @@ jobs: "Issues with a specific extension." "Feature request for the extension ecosystem." + area/documentation + - Description: Issues related to user-facing documentation and other content on the documentation website. + - Example Issues: + "A typo in a README file." + "DOCS: A command is not working as described in the documentation." + "A request for a new documentation page." + "Instructions missing for skills feature" + area/unknown - Description: Issues that do not clearly fit into any other defined area/ category, or where information is too limited to make a determination. Use this when no other area is appropriate. diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index 25b0cdf4ec..50dd56883e 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -63,7 +63,7 @@ jobs: echo '🔍 Finding issues missing area labels...' NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ - --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/unknown' --limit 100 --json number,title,body)" + --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)" echo '🔍 Finding issues missing kind labels...' NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ @@ -204,6 +204,7 @@ jobs: Categorization Guidelines (Area): area/agent: Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality area/core: User Interface, OS Support, Core Functionality + area/documentation: End-user and contributor-facing documentation, website-related area/enterprise: Telemetry, Policy, Quota / Licensing area/extensions: Gemini CLI extensions capability area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation diff --git a/.github/workflows/gemini-scheduled-stale-issue-closer.yml b/.github/workflows/gemini-scheduled-stale-issue-closer.yml index c7aef65a73..2b7b163d88 100644 --- a/.github/workflows/gemini-scheduled-stale-issue-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-issue-closer.yml @@ -21,6 +21,7 @@ defaults: jobs: close-stale-issues: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml index 4198945159..cc33848941 100644 --- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml @@ -23,6 +23,10 @@ jobs: steps: - name: 'Generate GitHub App Token' id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} uses: 'actions/create-github-app-token@v2' with: app-id: '${{ secrets.APP_ID }}' @@ -33,9 +37,11 @@ jobs: env: DRY_RUN: '${{ inputs.dry_run }}' with: - github-token: '${{ steps.generate_token.outputs.token }}' + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: | const dryRun = process.env.DRY_RUN === 'true'; + const fourteenDaysAgo = new Date(); + fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); @@ -52,48 +58,38 @@ jobs: for (const m of members) maintainerLogins.add(m.login.toLowerCase()); core.info(`Successfully fetched ${members.length} team members from ${team_slug}`); } catch (e) { - core.warning(`Failed to fetch team members from ${team_slug}: ${e.message}`); + // Silently skip if permissions are insufficient; we will rely on author_association + core.debug(`Skipped team fetch for ${team_slug}: ${e.message}`); } } - const isGooglerCache = new Map(); - const isGoogler = async (login) => { - if (isGooglerCache.has(login)) return isGooglerCache.get(login); + const isMaintainer = async (login, assoc) => { + // Reliably identify maintainers using authorAssociation (provided by GitHub) + // and organization membership (if available). + const isTeamMember = maintainerLogins.has(login.toLowerCase()); + const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc); + if (isTeamMember || isRepoMaintainer) return true; + + // Fallback: Check if user belongs to the 'google' or 'googlers' orgs (requires permission) try { - // Check membership in 'googlers' or 'google' orgs const orgs = ['googlers', 'google']; for (const org of orgs) { try { - await github.rest.orgs.checkMembershipForUser({ - org: org, - username: login - }); - core.info(`User ${login} is a member of ${org} organization.`); - isGooglerCache.set(login, true); + await github.rest.orgs.checkMembershipForUser({ org: org, username: login }); return true; } catch (e) { - // 404 just means they aren't a member, which is fine if (e.status !== 404) throw e; } } } catch (e) { - core.warning(`Failed to check org membership for ${login}: ${e.message}`); + // Gracefully ignore failures here } - isGooglerCache.set(login, false); return false; }; - const isMaintainer = async (login, assoc) => { - const isTeamMember = maintainerLogins.has(login.toLowerCase()); - const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc); - if (isTeamMember || isRepoMaintainer) return true; - - return await isGoogler(login); - }; - - // 2. Determine which PRs to check + // 2. Fetch all open PRs let prs = []; if (context.eventName === 'pull_request') { const { data: pr } = await github.rest.pulls.get({ @@ -114,64 +110,77 @@ jobs: for (const pr of prs) { const maintainerPr = await isMaintainer(pr.user.login, pr.author_association); const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]'); + if (maintainerPr || isBot) continue; - // Detection Logic for Linked Issues - // Check 1: Official GitHub "Closing Issue" link (GraphQL) - const linkedIssueQuery = `query($owner:String!, $repo:String!, $number:Int!) { + // Helper: Fetch labels and linked issues via GraphQL + const prDetailsQuery = `query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { pullRequest(number:$number) { - closingIssuesReferences(first: 1) { totalCount } + closingIssuesReferences(first: 10) { + nodes { + number + labels(first: 20) { + nodes { name } + } + } + } } } }`; - let hasClosingLink = false; + let linkedIssues = []; try { - const res = await github.graphql(linkedIssueQuery, { + const res = await github.graphql(prDetailsQuery, { owner: context.repo.owner, repo: context.repo.repo, number: pr.number }); - hasClosingLink = res.repository.pullRequest.closingIssuesReferences.totalCount > 0; - } catch (e) {} - - // Check 2: Regex for mentions (e.g., "Related to #123", "Part of #123", "#123") - // We check for # followed by numbers or direct URLs to issues. - const body = pr.body || ''; - const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i; - const hasMentionLink = mentionRegex.test(body); - - const hasLinkedIssue = hasClosingLink || hasMentionLink; - - // Logic for Closed PRs (Auto-Reopen) - if (pr.state === 'closed' && context.eventName === 'pull_request' && context.payload.action === 'edited') { - if (hasLinkedIssue) { - core.info(`PR #${pr.number} now has a linked issue. Reopening.`); - if (!dryRun) { - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - state: 'open' - }); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: "Thank you for linking an issue! This pull request has been automatically reopened." - }); - } - } - continue; + linkedIssues = res.repository.pullRequest.closingIssuesReferences.nodes; + } catch (e) { + core.warning(`GraphQL fetch failed for PR #${pr.number}: ${e.message}`); } - // Logic for Open PRs (Immediate Closure) - if (pr.state === 'open' && !maintainerPr && !hasLinkedIssue && !isBot) { - core.info(`PR #${pr.number} is missing a linked issue. Closing.`); + // Check for mentions in body as fallback (regex) + const body = pr.body || ''; + const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i; + const matches = body.match(mentionRegex); + if (matches && linkedIssues.length === 0) { + const issueNumber = parseInt(matches[1]); + try { + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + linkedIssues = [{ number: issueNumber, labels: { nodes: issue.labels.map(l => ({ name: l.name })) } }]; + } catch (e) {} + } + + // 3. Enforcement Logic + const prLabels = pr.labels.map(l => l.name.toLowerCase()); + const hasHelpWanted = prLabels.includes('help wanted') || + linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === 'help wanted')); + + const hasMaintainerOnly = prLabels.includes('🔒 maintainer only') || + linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === '🔒 maintainer only')); + + const hasLinkedIssue = linkedIssues.length > 0; + + // Closure Policy: No help-wanted label = Close after 14 days + if (pr.state === 'open' && !hasHelpWanted && !hasMaintainerOnly) { + const prCreatedAt = new Date(pr.created_at); + + // We give a 14-day grace period for non-help-wanted PRs to be manually reviewed/labeled by an EM + if (prCreatedAt > fourteenDaysAgo) { + core.info(`PR #${pr.number} is new and lacks 'help wanted'. Giving 14-day grace period for EM review.`); + continue; + } + + core.info(`PR #${pr.number} is older than 14 days and lacks 'help wanted' association. Closing.`); if (!dryRun) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, - body: "Hi there! Thank you for your contribution to Gemini CLI. \n\nTo improve our contribution process and better track changes, we now require all pull requests to be associated with an existing issue, as announced in our [recent discussion](https://github.com/google-gemini/gemini-cli/discussions/16706) and as detailed in our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md#1-link-to-an-existing-issue).\n\nThis pull request is being closed because it is not currently linked to an issue. **Once you have updated the description of this PR to link an issue (e.g., by adding `Fixes #123` or `Related to #123`), it will be automatically reopened.**\n\n**How to link an issue:**\nAdd a keyword followed by the issue number (e.g., `Fixes #123`) in the description of your pull request. For more details on supported keywords and how linking works, please refer to the [GitHub Documentation on linking pull requests to issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).\n\nThank you for your understanding and for being a part of our community!" + body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we have updated our contribution policy (see [Discussion #17383](https://github.com/google-gemini/gemini-cli/discussions/17383)). \n\n**We only *guarantee* review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.** All other community pull requests are subject to closure after 14 days if they do not align with our current focus areas. For this reason, we strongly recommend that contributors only submit pull requests against issues explicitly labeled as **'help-wanted'**. \n\nThis pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding and for being part of our community!" }); await github.rest.pulls.update({ owner: context.repo.owner, @@ -183,27 +192,22 @@ jobs: continue; } - // Staleness check (Scheduled runs only) - if (pr.state === 'open' && context.eventName !== 'pull_request') { - const labels = pr.labels.map(l => l.name.toLowerCase()); - if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue; + // Also check for linked issue even if it has help wanted (redundant but safe) + if (pr.state === 'open' && !hasLinkedIssue) { + // Already covered by hasHelpWanted check above, but good for future-proofing + continue; + } + // 4. Staleness Check (Scheduled only) + if (pr.state === 'open' && context.eventName !== 'pull_request') { // Skip PRs that were created less than 30 days ago - they cannot be stale yet const prCreatedAt = new Date(pr.created_at); - if (prCreatedAt > thirtyDaysAgo) { - const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24)); - core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`); - continue; - } + if (prCreatedAt > thirtyDaysAgo) continue; - // Initialize lastActivity to PR creation date (not epoch) as a safety baseline. - // This ensures we never incorrectly mark a PR as stale due to failed activity lookups. let lastActivity = new Date(pr.created_at); try { const reviews = await github.paginate(github.rest.pulls.listReviews, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number + owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number }); for (const r of reviews) { if (await isMaintainer(r.user.login, r.author_association)) { @@ -212,9 +216,7 @@ jobs: } } const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number + owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number }); for (const c of comments) { if (await isMaintainer(c.user.login, c.author_association)) { @@ -222,25 +224,23 @@ jobs: if (d > lastActivity) lastActivity = d; } } - } catch (e) { - core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`); - } - - // For maintainer PRs, the PR creation itself counts as maintainer activity. - // (Now redundant since we initialize to pr.created_at, but kept for clarity) - if (maintainerPr) { - const d = new Date(pr.created_at); - if (d > lastActivity) lastActivity = d; - } + } catch (e) {} if (lastActivity < thirtyDaysAgo) { - core.info(`PR #${pr.number} is stale.`); + const labels = pr.labels.map(l => l.name.toLowerCase()); + const isProtected = labels.includes('help wanted') || labels.includes('🔒 maintainer only'); + if (isProtected) { + core.info(`PR #${pr.number} is stale but has a protected label. Skipping closure.`); + continue; + } + + core.info(`PR #${pr.number} is stale (no maintainer activity for 30+ days). Closing.`); if (!dryRun) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, - body: "Hi there! Thank you for your contribution to Gemini CLI. We really appreciate the time and effort you've put into this pull request.\n\nTo keep our backlog manageable and ensure we're focusing on current priorities, we are closing pull requests that haven't seen maintainer activity for 30 days. Currently, the team is prioritizing work associated with **🔒 maintainer only** or **help wanted** issues.\n\nIf you believe this change is still critical, please feel free to comment with updated details. Otherwise, we encourage contributors to focus on open issues labeled as **help wanted**. Thank you for your understanding!" + body: "Hi there! Thank you for your contribution. To keep our backlog manageable, we are closing pull requests that haven't seen maintainer activity for 30 days. If you're still working on this, please let us know!" }); await github.rest.pulls.update({ owner: context.repo.owner, diff --git a/.github/workflows/gemini-self-assign-issue.yml b/.github/workflows/gemini-self-assign-issue.yml index c0c79e5c04..454fc4f41b 100644 --- a/.github/workflows/gemini-self-assign-issue.yml +++ b/.github/workflows/gemini-self-assign-issue.yml @@ -25,7 +25,7 @@ jobs: if: |- github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issue_comment' && - contains(github.event.comment.body, '/assign') + (contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign')) runs-on: 'ubuntu-latest' steps: - name: 'Generate GitHub App Token' @@ -38,6 +38,7 @@ jobs: permission-issues: 'write' - name: 'Assign issue to user' + if: "contains(github.event.comment.body, '/assign')" uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token }}' @@ -108,3 +109,42 @@ jobs: issue_number: issueNumber, body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).` }); + + - name: 'Unassign issue from user' + if: "contains(github.event.comment.body, '/unassign')" + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const issueNumber = context.issue.number; + const commenter = context.actor; + const owner = context.repo.owner; + const repo = context.repo.repo; + const commentBody = context.payload.comment.body.trim(); + + if (commentBody !== '/unassign') { + return; + } + + const issue = await github.rest.issues.get({ + owner: owner, + repo: repo, + issue_number: issueNumber, + }); + + const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter); + + if (isAssigned) { + await github.rest.issues.removeAssignees({ + owner: owner, + repo: repo, + issue_number: issueNumber, + assignees: [commenter] + }); + await github.rest.issues.createComment({ + owner: owner, + repo: repo, + issue_number: issueNumber, + body: `👋 @${commenter}, you have been unassigned from this issue.` + }); + } diff --git a/.github/workflows/label-backlog-child-issues.yml b/.github/workflows/label-backlog-child-issues.yml index b11f509f80..a819bf4e71 100644 --- a/.github/workflows/label-backlog-child-issues.yml +++ b/.github/workflows/label-backlog-child-issues.yml @@ -14,7 +14,7 @@ permissions: jobs: # Event-based: Quick reaction to new/edited issues in THIS repo labeler: - if: "github.event_name == 'issues'" + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' @@ -36,7 +36,7 @@ jobs: # Scheduled/Manual: Recursive sync across multiple repos sync-maintainer-labels: - if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' diff --git a/.github/workflows/label-workstream-rollup.yml b/.github/workflows/label-workstream-rollup.yml index 35840cfe6f..97d699d09b 100644 --- a/.github/workflows/label-workstream-rollup.yml +++ b/.github/workflows/label-workstream-rollup.yml @@ -9,6 +9,7 @@ on: jobs: labeler: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' diff --git a/.github/workflows/release-change-tags.yml b/.github/workflows/release-change-tags.yml index 6184850677..c7c3f3f2d2 100644 --- a/.github/workflows/release-change-tags.yml +++ b/.github/workflows/release-change-tags.yml @@ -32,6 +32,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml index c9d2290a1c..f03bd52127 100644 --- a/.github/workflows/release-manual.yml +++ b/.github/workflows/release-manual.yml @@ -47,6 +47,7 @@ on: jobs: release: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 0a04e93517..8d453f7376 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -145,7 +145,7 @@ jobs: branch-name: 'release/${{ steps.nightly_version.outputs.RELEASE_TAG }}' pr-title: 'chore/release: bump version to ${{ steps.nightly_version.outputs.RELEASE_VERSION }}' pr-body: 'Automated version bump for nightly release.' - github-token: '${{ secrets.GITHUB_TOKEN }}' + github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ steps.vars.outputs.is_dry_run }}' working-directory: './release' diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 8a681dadf6..13bb2c2ca8 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -22,6 +22,7 @@ on: jobs: generate-release-notes: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' @@ -94,6 +95,8 @@ jobs: This PR contains the auto-generated changelog for the ${{ steps.release_info.outputs.VERSION }} release. Please review and merge. + + Related to #18505 branch: 'changelog-${{ steps.release_info.outputs.VERSION }}' base: 'main' team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers' diff --git a/.github/workflows/release-patch-0-from-comment.yml b/.github/workflows/release-patch-0-from-comment.yml index d73ba82abd..2bb7c27c7b 100644 --- a/.github/workflows/release-patch-0-from-comment.yml +++ b/.github/workflows/release-patch-0-from-comment.yml @@ -120,6 +120,9 @@ jobs: if (recentRuns.length > 0) { core.setOutput('dispatched_run_urls', recentRuns.map(r => r.html_url).join(',')); core.setOutput('dispatched_run_ids', recentRuns.map(r => r.id).join(',')); + + const markdownLinks = recentRuns.map(r => `- [View dispatched workflow run](${r.html_url})`).join('\n'); + core.setOutput('dispatched_run_links', markdownLinks); } - name: 'Comment on Failure' @@ -138,16 +141,19 @@ jobs: token: '${{ secrets.GITHUB_TOKEN }}' issue-number: '${{ github.event.issue.number }}' body: | - ✅ **Patch workflow(s) dispatched successfully!** + 🚀 **[Step 1/4] Patch workflow(s) waiting for approval!** **📋 Details:** - **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}` - **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}` - **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }} + **⏳ Status:** The patch creation workflow has been triggered and is waiting for deployment approval. Please visit the specific workflow links below and approve the runs. + **🔗 Track Progress:** - - [View patch workflows](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) - - [This workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + ${{ steps.dispatch_patch.outputs.dispatched_run_links }} + - [View patch workflow history](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) + - [This trigger workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) - name: 'Final Status Comment - Dispatch Success (No URL)' if: "always() && startsWith(github.event.comment.body, '/patch') && steps.dispatch_patch.outcome == 'success' && !steps.dispatch_patch.outputs.dispatched_run_urls" @@ -156,16 +162,18 @@ jobs: token: '${{ secrets.GITHUB_TOKEN }}' issue-number: '${{ github.event.issue.number }}' body: | - ✅ **Patch workflow(s) dispatched successfully!** + 🚀 **[Step 1/4] Patch workflow(s) waiting for approval!** **📋 Details:** - **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}` - **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}` - **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }} + **⏳ Status:** The patch creation workflow has been triggered and is waiting for deployment approval. Please visit the workflow history link below and approve the runs. + **🔗 Track Progress:** - - [View patch workflows](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) - - [This workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + - [View patch workflow history](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) + - [This trigger workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) - name: 'Final Status Comment - Failure' if: "always() && startsWith(github.event.comment.body, '/patch') && (steps.dispatch_patch.outcome == 'failure' || steps.dispatch_patch.outcome == 'cancelled')" @@ -174,7 +182,7 @@ jobs: token: '${{ secrets.GITHUB_TOKEN }}' issue-number: '${{ github.event.issue.number }}' body: | - ❌ **Patch workflow dispatch failed!** + ❌ **[Step 1/4] Patch workflow dispatch failed!** There was an error dispatching the patch creation workflow. diff --git a/.github/workflows/release-promote.yml b/.github/workflows/release-promote.yml index d5c16b94fe..b822ce2f80 100644 --- a/.github/workflows/release-promote.yml +++ b/.github/workflows/release-promote.yml @@ -335,6 +335,7 @@ jobs: name: 'Create Nightly PR' needs: ['publish-stable', 'calculate-versions'] runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: contents: 'write' pull-requests: 'write' @@ -397,7 +398,7 @@ jobs: branch-name: '${{ steps.release_branch.outputs.BRANCH_NAME }}' pr-title: 'chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' pr-body: 'Automated version bump to prepare for the next nightly release.' - github-token: '${{ secrets.GITHUB_TOKEN }}' + github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ github.event.inputs.dry_run }}' - name: 'Create Issue on Failure' diff --git a/.github/workflows/release-rollback.yml b/.github/workflows/release-rollback.yml index 75c2d0c799..db91457b1a 100644 --- a/.github/workflows/release-rollback.yml +++ b/.github/workflows/release-rollback.yml @@ -42,6 +42,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" runs-on: 'ubuntu-latest' permissions: @@ -203,7 +204,7 @@ jobs: run: | ROLLBACK_COMMIT=$(git rev-parse -q --verify "$TARGET_TAG") if [ "$ROLLBACK_COMMIT" != "$TARGET_HASH" ]; then - echo '❌ Failed to add tag $TARGET_TAG to commit $TARGET_HASH' + echo "❌ Failed to add tag ${TARGET_TAG} to commit ${TARGET_HASH}" echo '❌ This means the tag was not added, and the workflow should fail.' exit 1 fi diff --git a/.github/workflows/release-sandbox.yml b/.github/workflows/release-sandbox.yml index f1deb0380c..2c7de7a0f5 100644 --- a/.github/workflows/release-sandbox.yml +++ b/.github/workflows/release-sandbox.yml @@ -16,6 +16,7 @@ on: jobs: build: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'read' diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index caeb0bebe0..29903dfbe8 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -20,6 +20,7 @@ on: jobs: smoke-test: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' diff --git a/.github/workflows/test-build-binary.yml b/.github/workflows/test-build-binary.yml new file mode 100644 index 0000000000..f11181a9f0 --- /dev/null +++ b/.github/workflows/test-build-binary.yml @@ -0,0 +1,160 @@ +name: 'Test Build Binary' + +on: + workflow_dispatch: + +permissions: + contents: 'read' + +defaults: + run: + shell: 'bash' + +jobs: + build-node-binary: + name: 'Build Binary (${{ matrix.os }})' + runs-on: '${{ matrix.os }}' + strategy: + fail-fast: false + matrix: + include: + - os: 'ubuntu-latest' + platform_name: 'linux-x64' + arch: 'x64' + - os: 'windows-latest' + platform_name: 'win32-x64' + arch: 'x64' + - os: 'macos-latest' # Apple Silicon (ARM64) + platform_name: 'darwin-arm64' + arch: 'arm64' + - os: 'macos-latest' # Intel (x64) running on ARM via Rosetta + platform_name: 'darwin-x64' + arch: 'x64' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@v4' + + - name: 'Optimize Windows Performance' + if: "matrix.os == 'windows-latest'" + run: | + Set-MpPreference -DisableRealtimeMonitoring $true + Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue + Set-Service -Name "wsearch" -StartupType Disabled + Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue + Set-Service -Name "SysMain" -StartupType Disabled + shell: 'powershell' + + - name: 'Set up Node.js' + uses: 'actions/setup-node@v4' + with: + node-version-file: '.nvmrc' + architecture: '${{ matrix.arch }}' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Check Secrets' + id: 'check_secrets' + run: | + echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT" + echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT" + + - name: 'Setup Windows SDK (Windows)' + if: "matrix.os == 'windows-latest'" + uses: 'microsoft/setup-msbuild@v2' + + - name: 'Add Signtool to Path (Windows)' + if: "matrix.os == 'windows-latest'" + run: | + $signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName + echo "Found signtool at: $signtoolPath" + echo "$signtoolPath" >> $env:GITHUB_PATH + shell: 'pwsh' + + - name: 'Setup macOS Keychain' + if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'" + env: + BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}' + P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}' + KEYCHAIN_PASSWORD: 'temp-password' + run: | + # Create the P12 file + echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12 + + # Create a temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + + # Import the certificate + security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign + + # Allow codesign to access it + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain + + # Set Identity for build script + echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV" + + - name: 'Setup Windows Certificate' + if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'" + env: + PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}' + PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}' + run: | + $pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64") + $certPath = Join-Path (Get-Location) "cert.pfx" + [IO.File]::WriteAllBytes($certPath, $pfx_cert_byte) + echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV + echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV + shell: 'pwsh' + + - name: 'Build Binary' + run: 'npm run build:binary' + + - name: 'Build Core Package' + run: 'npm run build -w @google/gemini-cli-core' + + - name: 'Verify Output Exists' + run: | + if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then + echo "Binary found at dist/${{ matrix.platform_name }}/gemini" + elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then + echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe" + else + echo "Error: Binary not found in dist/${{ matrix.platform_name }}/" + ls -R dist/ + exit 1 + fi + + - name: 'Smoke Test Binary' + run: | + echo "Running binary smoke test..." + if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then + "./dist/${{ matrix.platform_name }}/gemini.exe" --version + else + "./dist/${{ matrix.platform_name }}/gemini" --version + fi + + - name: 'Run Integration Tests' + if: "github.event_name != 'pull_request'" + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + run: | + echo "Running integration tests with binary..." + if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then + BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")" + else + BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini" + fi + echo "Using binary at $BINARY_PATH" + export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH" + npm run test:integration:sandbox:none -- --testTimeout=600000 + + - name: 'Upload Artifact' + uses: 'actions/upload-artifact@v4' + with: + name: 'gemini-cli-${{ matrix.platform_name }}' + path: 'dist/${{ matrix.platform_name }}/' + retention-days: 5 diff --git a/.github/workflows/trigger_e2e.yml b/.github/workflows/trigger_e2e.yml index d83dc1d7cf..56da2727c5 100644 --- a/.github/workflows/trigger_e2e.yml +++ b/.github/workflows/trigger_e2e.yml @@ -15,6 +15,7 @@ on: jobs: save_repo_name: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Save Repo name' @@ -23,14 +24,15 @@ jobs: HEAD_SHA: '${{ github.event.inputs.head_sha || github.event.pull_request.head.sha }}' run: | mkdir -p ./pr - echo '${REPO_NAME}' > ./pr/repo_name - echo '${HEAD_SHA}' > ./pr/head_sha + echo "${REPO_NAME}" > ./pr/repo_name + echo "${HEAD_SHA}" > ./pr/head_sha - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'repo_name' path: 'pr/' trigger_e2e: name: 'Trigger e2e' + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'gemini-cli-ubuntu-16-core' steps: - id: 'trigger-e2e' diff --git a/.github/workflows/unassign-inactive-assignees.yml b/.github/workflows/unassign-inactive-assignees.yml new file mode 100644 index 0000000000..dd09f0feaf --- /dev/null +++ b/.github/workflows/unassign-inactive-assignees.yml @@ -0,0 +1,315 @@ +name: 'Unassign Inactive Issue Assignees' + +# This workflow runs daily and scans every open "help wanted" issue that has +# one or more assignees. For each assignee it checks whether they have a +# non-draft pull request (open and ready for review, or already merged) that +# is linked to the issue. Draft PRs are intentionally excluded so that +# contributors cannot reset the check by opening a no-op PR. If no +# qualifying PR is found within 7 days of assignment the assignee is +# automatically removed and a friendly comment is posted so that other +# contributors can pick up the work. +# Maintainers, org members, and collaborators (anyone with write access or +# above) are always exempted and will never be auto-unassigned. + +on: + schedule: + - cron: '0 9 * * *' # Every day at 09:00 UTC + workflow_dispatch: + inputs: + dry_run: + description: 'Run in dry-run mode (no changes will be applied)' + required: false + default: false + type: 'boolean' + +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +jobs: + unassign-inactive-assignees: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + permissions: + issues: 'write' + + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@v2' + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + + - name: 'Unassign inactive assignees' + uses: 'actions/github-script@v7' + env: + DRY_RUN: '${{ inputs.dry_run }}' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const dryRun = process.env.DRY_RUN === 'true'; + if (dryRun) { + core.info('DRY RUN MODE ENABLED: No changes will be applied.'); + } + + const owner = context.repo.owner; + const repo = context.repo.repo; + const GRACE_PERIOD_DAYS = 7; + const now = new Date(); + + let maintainerLogins = new Set(); + const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs']; + + for (const team_slug of teams) { + try { + const members = await github.paginate(github.rest.teams.listMembersInOrg, { + org: owner, + team_slug, + }); + for (const m of members) maintainerLogins.add(m.login.toLowerCase()); + core.info(`Fetched ${members.length} members from team ${team_slug}.`); + } catch (e) { + core.warning(`Could not fetch team ${team_slug}: ${e.message}`); + } + } + + const isGooglerCache = new Map(); + const isGoogler = async (login) => { + if (isGooglerCache.has(login)) return isGooglerCache.get(login); + try { + for (const org of ['googlers', 'google']) { + try { + await github.rest.orgs.checkMembershipForUser({ org, username: login }); + isGooglerCache.set(login, true); + return true; + } catch (e) { + if (e.status !== 404) throw e; + } + } + } catch (e) { + core.warning(`Could not check org membership for ${login}: ${e.message}`); + } + isGooglerCache.set(login, false); + return false; + }; + + const permissionCache = new Map(); + const isPrivilegedUser = async (login) => { + if (maintainerLogins.has(login.toLowerCase())) return true; + + if (permissionCache.has(login)) return permissionCache.get(login); + + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: login, + }); + const privileged = ['admin', 'maintain', 'write', 'triage'].includes(data.permission); + permissionCache.set(login, privileged); + if (privileged) { + core.info(` @${login} is a repo collaborator (${data.permission}) — exempt.`); + return true; + } + } catch (e) { + if (e.status !== 404) { + core.warning(`Could not check permission for ${login}: ${e.message}`); + } + } + + const googler = await isGoogler(login); + permissionCache.set(login, googler); + return googler; + }; + + core.info('Fetching open "help wanted" issues with assignees...'); + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + labels: 'help wanted', + per_page: 100, + }); + + const assignedIssues = issues.filter( + (issue) => !issue.pull_request && issue.assignees && issue.assignees.length > 0 + ); + + core.info(`Found ${assignedIssues.length} assigned "help wanted" issues.`); + + let totalUnassigned = 0; + + let timelineEvents = []; + try { + timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner, + repo, + issue_number: issue.number, + per_page: 100, + mediaType: { previews: ['mockingbird'] }, + }); + } catch (err) { + core.warning(`Could not fetch timeline for issue #${issue.number}: ${err.message}`); + continue; + } + + const assignedAtMap = new Map(); + + for (const event of timelineEvents) { + if (event.event === 'assigned' && event.assignee) { + const login = event.assignee.login.toLowerCase(); + const at = new Date(event.created_at); + assignedAtMap.set(login, at); + } else if (event.event === 'unassigned' && event.assignee) { + assignedAtMap.delete(event.assignee.login.toLowerCase()); + } + } + + const linkedPRAuthorSet = new Set(); + const seenPRKeys = new Set(); + + for (const event of timelineEvents) { + if ( + event.event !== 'cross-referenced' || + !event.source || + event.source.type !== 'pull_request' || + !event.source.issue || + !event.source.issue.user || + !event.source.issue.number || + !event.source.issue.repository + ) continue; + + const prOwner = event.source.issue.repository.owner.login; + const prRepo = event.source.issue.repository.name; + const prNumber = event.source.issue.number; + const prAuthor = event.source.issue.user.login.toLowerCase(); + const prKey = `${prOwner}/${prRepo}#${prNumber}`; + + if (seenPRKeys.has(prKey)) continue; + seenPRKeys.add(prKey); + + try { + const { data: pr } = await github.rest.pulls.get({ + owner: prOwner, + repo: prRepo, + pull_number: prNumber, + }); + + const isReady = (pr.state === 'open' && !pr.draft) || + (pr.state === 'closed' && pr.merged_at !== null); + + core.info( + ` PR ${prKey} by @${prAuthor}: ` + + `state=${pr.state}, draft=${pr.draft}, merged=${!!pr.merged_at} → ` + + (isReady ? 'qualifies' : 'does NOT qualify (draft or closed without merge)') + ); + + if (isReady) linkedPRAuthorSet.add(prAuthor); + } catch (err) { + core.warning(`Could not fetch PR ${prKey}: ${err.message}`); + } + } + + const assigneesToRemove = []; + + for (const assignee of issue.assignees) { + const login = assignee.login.toLowerCase(); + + if (await isPrivilegedUser(assignee.login)) { + core.info(` @${assignee.login}: privileged user — skipping.`); + continue; + } + + const assignedAt = assignedAtMap.get(login); + + if (!assignedAt) { + core.warning( + `No 'assigned' event found for @${login} on issue #${issue.number}; ` + + `falling back to issue creation date (${issue.created_at}).` + ); + assignedAtMap.set(login, new Date(issue.created_at)); + } + const resolvedAssignedAt = assignedAtMap.get(login); + + const daysSinceAssignment = (now - resolvedAssignedAt) / (1000 * 60 * 60 * 24); + + core.info( + ` @${login}: assigned ${daysSinceAssignment.toFixed(1)} day(s) ago, ` + + `ready-for-review PR: ${linkedPRAuthorSet.has(login) ? 'yes' : 'no'}` + ); + + if (daysSinceAssignment < GRACE_PERIOD_DAYS) { + core.info(` → within grace period, skipping.`); + continue; + } + + if (linkedPRAuthorSet.has(login)) { + core.info(` → ready-for-review PR found, keeping assignment.`); + continue; + } + + core.info(` → no ready-for-review PR after ${GRACE_PERIOD_DAYS} days, will unassign.`); + assigneesToRemove.push(assignee.login); + } + + if (assigneesToRemove.length === 0) { + continue; + } + + if (!dryRun) { + try { + await github.rest.issues.removeAssignees({ + owner, + repo, + issue_number: issue.number, + assignees: assigneesToRemove, + }); + } catch (err) { + core.warning( + `Failed to unassign ${assigneesToRemove.join(', ')} from issue #${issue.number}: ${err.message}` + ); + continue; + } + + const mentionList = assigneesToRemove.map((l) => `@${l}`).join(', '); + const commentBody = + `👋 ${mentionList} — it has been more than ${GRACE_PERIOD_DAYS} days since ` + + `you were assigned to this issue and we could not find a pull request ` + + `ready for review.\n\n` + + `To keep the backlog moving and ensure issues stay accessible to all ` + + `contributors, we require a PR that is open and ready for review (not a ` + + `draft) within ${GRACE_PERIOD_DAYS} days of assignment.\n\n` + + `We are automatically unassigning you so that other contributors can pick ` + + `this up. If you are still actively working on this, please:\n` + + `1. Re-assign yourself by commenting \`/assign\`.\n` + + `2. Open a PR (not a draft) linked to this issue (e.g. \`Fixes #${issue.number}\`) ` + + `within ${GRACE_PERIOD_DAYS} days so the automation knows real progress is being made.\n\n` + + `Thank you for your contribution — we hope to see a PR from you soon! 🙏`; + + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: commentBody, + }); + } catch (err) { + core.warning( + `Failed to post comment on issue #${issue.number}: ${err.message}` + ); + } + } + + totalUnassigned += assigneesToRemove.length; + core.info( + ` ${dryRun ? '[DRY RUN] Would have unassigned' : 'Unassigned'}: ${assigneesToRemove.join(', ')}` + ); + } + + core.info(`\nDone. Total assignees ${dryRun ? 'that would be' : ''} unassigned: ${totalUnassigned}`); diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index edf0995ddd..20a9f51b8a 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -28,6 +28,7 @@ on: jobs: verify-release: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index 0438549485..ebb94151e8 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,6 @@ gemini-debug.log .genkit .gemini-clipboard/ .eslintcache -evals/logs/ \ No newline at end of file +evals/logs/ + +temp_agents/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 3661ecf9c2..3197edbbfc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,9 @@ "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28e3c775d3..c6c619219c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,26 +60,54 @@ All submissions, including submissions by project members, require review. We use [GitHub pull requests](https://docs.github.com/articles/about-pull-requests) for this purpose. -If your pull request involves changes to `packages/cli` (the frontend), we -recommend running our automated frontend review tool. **Note: This tool is -currently experimental.** It helps detect common React anti-patterns, testing -issues, and other frontend-specific best practices that are easy to miss. +To assist with the review process, we provide an automated review tool that +helps detect common anti-patterns, testing issues, and other best practices that +are easy to miss. -To run the review tool, enter the following command from within Gemini CLI: +#### Using the automated review tool -```text -/review-frontend -``` +You can run the review tool in two ways: -Replace `` with your pull request number. Authors are encouraged to -run this on their own PRs for self-review, and reviewers should use it to -augment their manual review process. +1. **Using the helper script (Recommended):** We provide a script that + automatically handles checking out the PR into a separate worktree, + installing dependencies, building the project, and launching the review + tool. -### Self assigning issues + ```bash + ./scripts/review.sh [model] + ``` -To assign an issue to yourself, simply add a comment with the text `/assign`. -The comment must contain only that text and nothing else. This command will -assign the issue to you, provided it is not already assigned. + **Warning:** If you run `scripts/review.sh`, you must have first verified + that the code for the PR being reviewed is safe to run and does not contain + data exfiltration attacks. + + **Authors are strongly encouraged to run this script on their own PRs** + immediately after creation. This allows you to catch and fix simple issues + locally before a maintainer performs a full review. + + **Note on Models:** By default, the script uses the latest Pro model + (`gemini-3.1-pro-preview`). If you do not have enough Pro quota, you can run + it with the latest Flash model instead: + `./scripts/review.sh gemini-3-flash-preview`. + +2. **Manually from within Gemini CLI:** If you already have the PR checked out + and built, you can run the tool directly from the CLI prompt: + + ```text + /review-frontend + ``` + +Replace `` with your pull request number. Reviewers should use this +tool to augment, not replace, their manual review process. + +### Self-assigning and unassigning issues + +To assign an issue to yourself, simply add a comment with the text `/assign`. To +unassign yourself from an issue, add a comment with the text `/unassign`. + +The comment must contain only that text and nothing else. These commands will +assign or unassign the issue as requested, provided the conditions are met +(e.g., an issue must be unassigned to be assigned). Please note that you can have a maximum of 3 issues assigned to you at any given time. @@ -264,7 +292,8 @@ npm run test:e2e ``` For more detailed information on the integration testing framework, please see -the [Integration Tests documentation](/docs/integration-tests.md). +the +[Integration Tests documentation](https://geminicli.com/docs/integration-tests). ### Linting and preflight checks @@ -317,29 +346,12 @@ npm run lint - Please adhere to the coding style, patterns, and conventions used throughout the existing codebase. -- Consult - [GEMINI.md](https://github.com/google-gemini/gemini-cli/blob/main/GEMINI.md) - (typically found in the project root) for specific instructions related to - AI-assisted development, including conventions for React, comments, and Git - usage. +- Consult [GEMINI.md](../GEMINI.md) (typically found in the project root) for + specific instructions related to AI-assisted development, including + conventions for React, comments, and Git usage. - **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. -### Project structure - -- `packages/`: Contains the individual sub-packages of the project. - - `a2a-server`: A2A server implementation for the Gemini CLI. (Experimental) - - `cli/`: The command-line interface. - - `core/`: The core backend logic for the Gemini CLI. - - `test-utils` Utilities for creating and cleaning temporary file systems for - testing. - - `vscode-ide-companion/`: The Gemini CLI Companion extension pairs with - Gemini CLI. -- `docs/`: Contains all project documentation. -- `scripts/`: Utility scripts for building, testing, and development tasks. - -For more detailed architecture, see `docs/architecture.md`. - ### Debugging #### VS Code @@ -545,7 +557,7 @@ Before submitting your documentation pull request, please: If you have questions about contributing documentation: -- Check our [FAQ](/docs/resources/faq.md). +- Check our [FAQ](https://geminicli.com/docs/resources/faq). - Review existing documentation for examples. - Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss your proposed changes. diff --git a/GEMINI.md b/GEMINI.md index f7017eab40..c08e486b22 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -22,9 +22,10 @@ powerful tool for developers. rendering. - `packages/core`: Backend logic, Gemini API orchestration, prompt construction, and tool execution. - - `packages/core/src/tools/`: Built-in tools for file system, shell, and web - operations. - `packages/a2a-server`: Experimental Agent-to-Agent server. + - `packages/sdk`: Programmatic SDK for embedding Gemini CLI capabilities. + - `packages/devtools`: Integrated developer tools (Network/Console inspector). + - `packages/test-utils`: Shared test utilities and test rig. - `packages/vscode-ide-companion`: VS Code extension pairing with the CLI. ## Building and Running @@ -58,10 +59,6 @@ powerful tool for developers. ## Development Conventions -- **Legacy Snippets:** `packages/core/src/prompts/snippets.legacy.ts` is a - snapshot of an older system prompt. Avoid changing the prompting verbiage to - preserve its historical behavior; however, structural changes to ensure - compilation or simplify the code are permitted. - **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires signing the Google CLA. - **Pull Requests:** Keep PRs small, focused, and linked to an existing issue. @@ -69,8 +66,6 @@ powerful tool for developers. `gh` CLI. - **Commit Messages:** Follow the [Conventional Commits](https://www.conventionalcommits.org/) standard. -- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink) - and `packages/core` (Backend logic). - **Imports:** Use specific imports and avoid restricted relative imports between packages (enforced by ESLint). - **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`), diff --git a/README.md b/README.md index f44a2e238d..03a7be1296 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License](https://img.shields.io/github/license/google-gemini/gemini-cli)](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE) [![View Code Wiki](https://assets.codewiki.google/readme-badge/static.svg)](https://codewiki.google/github.com/google-gemini/gemini-cli?utm_source=badge&utm_medium=github&utm_campaign=github.com/google-gemini/gemini-cli) -![Gemini CLI Screenshot](./docs/assets/gemini-screenshot.png) +![Gemini CLI Screenshot](/docs/assets/gemini-screenshot.png) Gemini CLI is an open-source AI agent that brings the power of Gemini directly into your terminal. It provides lightweight access to Gemini, giving you the @@ -77,7 +77,7 @@ See [Releases](./docs/releases.md) for more details. ### Preview -New preview releases will be published each week at UTC 2359 on Tuesdays. These +New preview releases will be published each week at UTC 23:59 on Tuesdays. These releases will not have been fully vetted and may contain regressions or other outstanding issues. Please help us test and install with `preview` tag. @@ -87,7 +87,7 @@ npm install -g @google/gemini-cli@preview ### Stable -- New stable releases will be published each week at UTC 2000 on Tuesdays, this +- New stable releases will be published each week at UTC 20:00 on Tuesdays, this will be the full promotion of last week's `preview` release + any bug fixes and validations. Use `latest` tag. @@ -97,7 +97,7 @@ npm install -g @google/gemini-cli@latest ### Nightly -- New releases will be published each day at UTC 0000. This will be all changes +- New releases will be published each day at UTC 00:00. This will be all changes from the main branch as represented at time of release. It should be assumed there are pending validations and issues. Use `nightly` tag. @@ -147,7 +147,7 @@ Integrate Gemini CLI directly into your GitHub workflows with Choose the authentication method that best fits your needs: -### Option 1: Login with Google (OAuth login using your Google Account) +### Option 1: Sign in with Google (OAuth login using your Google Account) **✨ Best for:** Individual developers as well as anyone who has a Gemini Code Assist License. (see @@ -161,7 +161,7 @@ for details) - **No API key management** - just sign in with your Google account - **Automatic updates** to latest models -#### Start Gemini CLI, then choose _Login with Google_ and follow the browser authentication flow when prompted +#### Start Gemini CLI, then choose _Sign in with Google_ and follow the browser authentication flow when prompted ```bash gemini @@ -282,14 +282,14 @@ gemini quickly. - [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed auth configuration. -- [**Configuration Guide**](./docs/get-started/configuration.md) - Settings and +- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and customization. -- [**Keyboard Shortcuts**](./docs/cli/keyboard-shortcuts.md) - Productivity - tips. +- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) - + Productivity tips. ### Core Features -- [**Commands Reference**](./docs/cli/commands.md) - All slash commands +- [**Commands Reference**](./docs/reference/commands.md) - All slash commands (`/help`, `/chat`, etc). - [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own reusable commands. @@ -301,7 +301,7 @@ gemini ### Tools & Extensions -- [**Built-in Tools Overview**](./docs/tools/index.md) +- [**Built-in Tools Overview**](./docs/reference/tools.md) - [File System Operations](./docs/tools/file-system.md) - [Shell Commands](./docs/tools/shell.md) - [Web Fetch & Search](./docs/tools/web-fetch.md) @@ -314,7 +314,6 @@ gemini - [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in automated workflows. -- [**Architecture Overview**](./docs/architecture.md) - How Gemini CLI works. - [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion. - [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution environments. @@ -323,15 +322,15 @@ gemini - [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a corporate environment. - [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking. -- [**Tools API Development**](./docs/core/tools-api.md) - Create custom tools. +- [**Tools reference**](./docs/reference/tools.md) - Built-in tools overview. - [**Local development**](./docs/local-development.md) - Local development tooling. ### Troubleshooting & Support -- [**Troubleshooting Guide**](./docs/troubleshooting.md) - Common issues and - solutions. -- [**FAQ**](./docs/faq.md) - Frequently asked questions. +- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common + issues and solutions. +- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions. - Use `/bug` command to report issues directly from the CLI. ### Using MCP Servers @@ -377,7 +376,8 @@ for planned features and priorities. ### Uninstall -See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions. +See the [Uninstall Guide](./docs/resources/uninstall.md) for removal +instructions. ## 📄 Legal diff --git a/docs/admin/enterprise-controls.md b/docs/admin/enterprise-controls.md index 8c9ba60a13..5792a6c5bc 100644 --- a/docs/admin/enterprise-controls.md +++ b/docs/admin/enterprise-controls.md @@ -106,6 +106,67 @@ organization. ensures users maintain final control over which permitted servers are actually active in their environment. +#### Required MCP Servers (preview) + +**Default**: empty + +Allows administrators to define MCP servers that are **always injected** into +the user's environment. Unlike the allowlist (which filters user-configured +servers), required servers are automatically added regardless of the user's +local configuration. + +**Required Servers Format:** + +```json +{ + "requiredMcpServers": { + "corp-compliance-tool": { + "url": "https://mcp.corp/compliance", + "type": "http", + "trust": true, + "description": "Corporate compliance tool" + }, + "internal-registry": { + "url": "https://registry.corp/mcp", + "type": "sse", + "authProviderType": "google_credentials", + "oauth": { + "scopes": ["https://www.googleapis.com/auth/scope"] + } + } + } +} +``` + +**Supported Fields:** + +- `url`: (Required) The full URL of the MCP server endpoint. +- `type`: (Required) The connection type (`sse` or `http`). +- `trust`: (Optional) If set to `true`, tool execution will not require user + approval. Defaults to `true` for required servers. +- `description`: (Optional) Human-readable description of the server. +- `authProviderType`: (Optional) Authentication provider (`dynamic_discovery`, + `google_credentials`, or `service_account_impersonation`). +- `oauth`: (Optional) OAuth configuration including `scopes`, `clientId`, and + `clientSecret`. +- `targetAudience`: (Optional) OAuth target audience for service-to-service + auth. +- `targetServiceAccount`: (Optional) Service account email to impersonate. +- `headers`: (Optional) Additional HTTP headers to send with requests. +- `includeTools` / `excludeTools`: (Optional) Tool filtering lists. +- `timeout`: (Optional) Timeout in milliseconds for MCP requests. + +**Client Enforcement Logic:** + +- Required servers are injected **after** allowlist filtering, so they are + always available even if the allowlist is active. +- If a required server has the **same name** as a locally configured server, the + admin configuration **completely overrides** the local one. +- Required servers only support remote transports (`sse`, `http`). Local + execution fields (`command`, `args`, `env`, `cwd`) are not supported. +- Required servers can coexist with allowlisted servers — both features work + independently. + ### Unmanaged Capabilities **Enabled/Disabled** | Default: disabled diff --git a/docs/assets/theme-ansi-dark.png b/docs/assets/theme-ansi-dark.png new file mode 100644 index 0000000000..10bcbd446e Binary files /dev/null and b/docs/assets/theme-ansi-dark.png differ diff --git a/docs/assets/theme-ansi-light.png b/docs/assets/theme-ansi-light.png index 9766ae7820..8973ef2f99 100644 Binary files a/docs/assets/theme-ansi-light.png and b/docs/assets/theme-ansi-light.png differ diff --git a/docs/assets/theme-ansi.png b/docs/assets/theme-ansi.png deleted file mode 100644 index 5d46dacab8..0000000000 Binary files a/docs/assets/theme-ansi.png and /dev/null differ diff --git a/docs/assets/theme-atom-one-dark.png b/docs/assets/theme-atom-one-dark.png new file mode 100644 index 0000000000..f81ba24812 Binary files /dev/null and b/docs/assets/theme-atom-one-dark.png differ diff --git a/docs/assets/theme-atom-one.png b/docs/assets/theme-atom-one.png deleted file mode 100644 index c2787d6b62..0000000000 Binary files a/docs/assets/theme-atom-one.png and /dev/null differ diff --git a/docs/assets/theme-ayu-dark.png b/docs/assets/theme-ayu-dark.png new file mode 100644 index 0000000000..3f5d01d110 Binary files /dev/null and b/docs/assets/theme-ayu-dark.png differ diff --git a/docs/assets/theme-ayu-light.png b/docs/assets/theme-ayu-light.png index f177465679..a276a13c05 100644 Binary files a/docs/assets/theme-ayu-light.png and b/docs/assets/theme-ayu-light.png differ diff --git a/docs/assets/theme-ayu.png b/docs/assets/theme-ayu.png deleted file mode 100644 index 99391f8271..0000000000 Binary files a/docs/assets/theme-ayu.png and /dev/null differ diff --git a/docs/assets/theme-default-dark.png b/docs/assets/theme-default-dark.png new file mode 100644 index 0000000000..2f3e2d7534 Binary files /dev/null and b/docs/assets/theme-default-dark.png differ diff --git a/docs/assets/theme-default-light.png b/docs/assets/theme-default-light.png index 829d4ed5cc..e454211fdb 100644 Binary files a/docs/assets/theme-default-light.png and b/docs/assets/theme-default-light.png differ diff --git a/docs/assets/theme-default.png b/docs/assets/theme-default.png deleted file mode 100644 index 0b93a33433..0000000000 Binary files a/docs/assets/theme-default.png and /dev/null differ diff --git a/docs/assets/theme-dracula-dark.png b/docs/assets/theme-dracula-dark.png new file mode 100644 index 0000000000..e95183708e Binary files /dev/null and b/docs/assets/theme-dracula-dark.png differ diff --git a/docs/assets/theme-dracula.png b/docs/assets/theme-dracula.png deleted file mode 100644 index 27213fbc5c..0000000000 Binary files a/docs/assets/theme-dracula.png and /dev/null differ diff --git a/docs/assets/theme-github-dark.png b/docs/assets/theme-github-dark.png new file mode 100644 index 0000000000..bcbd78ee29 Binary files /dev/null and b/docs/assets/theme-github-dark.png differ diff --git a/docs/assets/theme-github-light.png b/docs/assets/theme-github-light.png index 3cdc94aa49..35fbec5c8b 100644 Binary files a/docs/assets/theme-github-light.png and b/docs/assets/theme-github-light.png differ diff --git a/docs/assets/theme-github.png b/docs/assets/theme-github.png deleted file mode 100644 index a62961b650..0000000000 Binary files a/docs/assets/theme-github.png and /dev/null differ diff --git a/docs/assets/theme-google-light.png b/docs/assets/theme-google-light.png index 835ebc4bea..04f0aa8e46 100644 Binary files a/docs/assets/theme-google-light.png and b/docs/assets/theme-google-light.png differ diff --git a/docs/assets/theme-holiday-dark.png b/docs/assets/theme-holiday-dark.png new file mode 100644 index 0000000000..70416650d5 Binary files /dev/null and b/docs/assets/theme-holiday-dark.png differ diff --git a/docs/assets/theme-shades-of-purple-dark.png b/docs/assets/theme-shades-of-purple-dark.png new file mode 100644 index 0000000000..c3d2e50538 Binary files /dev/null and b/docs/assets/theme-shades-of-purple-dark.png differ diff --git a/docs/assets/theme-solarized-dark.png b/docs/assets/theme-solarized-dark.png new file mode 100644 index 0000000000..be57349283 Binary files /dev/null and b/docs/assets/theme-solarized-dark.png differ diff --git a/docs/assets/theme-solarized-light.png b/docs/assets/theme-solarized-light.png new file mode 100644 index 0000000000..838a3b6870 Binary files /dev/null and b/docs/assets/theme-solarized-light.png differ diff --git a/docs/assets/theme-xcode-light.png b/docs/assets/theme-xcode-light.png index eb056a5589..26f0a74314 100644 Binary files a/docs/assets/theme-xcode-light.png and b/docs/assets/theme-xcode-light.png differ diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 4a20557df7..d79bd910d1 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,81 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.34.0 - 2026-03-17 + +- **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help + you break down complex tasks and execute them systematically + ([#21713](https://github.com/google-gemini/gemini-cli/pull/21713) by @jerop). +- **Sandboxing Enhancements:** We've added native gVisor (runsc) and + experimental LXC container sandboxing support for safer execution environments + ([#21062](https://github.com/google-gemini/gemini-cli/pull/21062) by + @Zheyuan-Lin, [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) + by @h30s). + +## Announcements: v0.33.0 - 2026-03-11 + +- **Agent Architecture Enhancements:** Introduced HTTP authentication for A2A + remote agents and authenticated A2A agent card discovery + ([#20510](https://github.com/google-gemini/gemini-cli/pull/20510) by + @SandyTao520, [#20622](https://github.com/google-gemini/gemini-cli/pull/20622) + by @SandyTao520). +- **Plan Mode Updates:** Expanded Plan Mode with built-in research subagents, + annotation support for feedback, and a new `copy` subcommand + ([#20972](https://github.com/google-gemini/gemini-cli/pull/20972) by @Adib234, + [#20988](https://github.com/google-gemini/gemini-cli/pull/20988) by + @ruomengz). +- **CLI UX & Admin Controls:** Redesigned the header to be compact with an ASCII + icon, inverted context window display to show usage, and enabled a 30-day + default retention for chat history + ([#18713](https://github.com/google-gemini/gemini-cli/pull/18713) by + @keithguerin, [#20853](https://github.com/google-gemini/gemini-cli/pull/20853) + by @skeshive). + +## Announcements: v0.32.0 - 2026-03-03 + +- **Generalist Agent:** The generalist agent is now enabled to improve task + delegation and routing + ([#19665](https://github.com/google-gemini/gemini-cli/pull/19665) by + @joshualitt). +- **Model Steering in Workspace:** Added support for model steering directly in + the workspace + ([#20343](https://github.com/google-gemini/gemini-cli/pull/20343) by + @joshualitt). +- **Plan Mode Enhancements:** Users can now open and modify plans in an external + editor, and the planning workflow has been adapted to handle complex tasks + more effectively with multi-select options + ([#20348](https://github.com/google-gemini/gemini-cli/pull/20348) by @Adib234, + [#20465](https://github.com/google-gemini/gemini-cli/pull/20465) by @jerop). +- **Interactive Shell Autocompletion:** Introduced interactive shell + autocompletion for a more seamless experience + ([#20082](https://github.com/google-gemini/gemini-cli/pull/20082) by + @mrpmohiburrahman). +- **Parallel Extension Loading:** Extensions are now loaded in parallel to + improve startup times + ([#20229](https://github.com/google-gemini/gemini-cli/pull/20229) by + @scidomino). + +## Announcements: v0.31.0 - 2026-02-27 + +- **Gemini 3.1 Pro Preview:** Gemini CLI now supports the new Gemini 3.1 Pro + Preview model + ([#19676](https://github.com/google-gemini/gemini-cli/pull/19676) by + @sehoon38). +- **Experimental Browser Agent:** We've introduced a new experimental browser + agent to interact with web pages + ([#19284](https://github.com/google-gemini/gemini-cli/pull/19284) by + @gsquared94). +- **Policy Engine Updates:** The policy engine now supports project-level + policies, MCP server wildcards, and tool annotation matching + ([#18682](https://github.com/google-gemini/gemini-cli/pull/18682) by + @Abhijit-2592, + [#20024](https://github.com/google-gemini/gemini-cli/pull/20024) by @jerop). +- **Web Fetch Improvements:** We've implemented an experimental direct web fetch + feature and added rate limiting to mitigate DDoS risks + ([#19557](https://github.com/google-gemini/gemini-cli/pull/19557) by @mbleigh, + [#19567](https://github.com/google-gemini/gemini-cli/pull/19567) by + @mattKorwel). + ## Announcements: v0.30.0 - 2026-02-25 - **SDK & Custom Skills:** Introduced the initial SDK package, enabling dynamic @@ -61,10 +136,6 @@ on GitHub. ## Announcements: v0.28.0 - 2026-02-10 -- **Slash Command:** We've added a new `/prompt-suggest` slash command to help - you generate prompt suggestions - ([#17264](https://github.com/google-gemini/gemini-cli/pull/17264) by - @NTaylorMullen). - **IDE Support:** Gemini CLI now supports the Positron IDE ([#15047](https://github.com/google-gemini/gemini-cli/pull/15047) by @kapsner). @@ -104,8 +175,8 @@ on GitHub. ([#16638](https://github.com/google-gemini/gemini-cli/pull/16638) by @joshualitt). - **UI/UX Improvements:** You can now "Rewind" through your conversation history - ([#15717](https://github.com/google-gemini/gemini-cli/pull/15717) by @Adib234) - and use a new `/introspect` command for debugging. + ([#15717](https://github.com/google-gemini/gemini-cli/pull/15717) by + @Adib234). - **Core and Scheduler Refactoring:** The core scheduler has been significantly refactored to improve performance and reliability ([#16895](https://github.com/google-gemini/gemini-cli/pull/16895) by @@ -443,8 +514,9 @@ on GitHub. page in their default browser directly from the CLI using the `/extension` explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846) by [@JayadityaGit](https://github.com/JayadityaGit)). -- **Configurable compression:** Users can modify the compression threshold in - `/settings`. The default has been made more proactive +- **Configurable compression:** Users can modify the context compression + threshold in `/settings` (decimal with percentage display). The default has + been made more proactive ([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by [@scidomino](https://github.com/scidomino)). - **API key authentication:** Users can now securely enter and store their diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 8fb3f6aa87..e49ef1c652 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.30.0 +# Latest stable release: v0.34.0 -Released: February 25, 2026 +Released: March 17, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,323 +11,474 @@ npm install -g @google/gemini-cli ## Highlights -- **SDK & Custom Skills**: Introduced the initial SDK package, dynamic system - instructions, `SessionContext` for SDK tool calls, and support for custom - skills. -- **Policy Engine Enhancements**: Added a `--policy` flag for user-defined - policies, strict seatbelt profiles, and transitioned away from - `--allowed-tools`. -- **UI & Themes**: Introduced a generic searchable list for settings and - extensions, added Solarized Dark and Light themes, text wrapping capabilities - to markdown tables, and a clean UI toggle prototype. -- **Vim Support & Ctrl-Z**: Improved Vim support to provide a more complete - experience and added support for Ctrl-Z suspension. -- **Plan Mode & Tools**: Plan Mode now supports project exploration without - planning and skills can be enabled in plan mode. Tool output masking is - enabled by default, and core tool definitions have been centralized. +- **Plan Mode Enabled by Default**: The comprehensive planning capability is now + enabled by default, allowing for better structured task management and + execution. +- **Enhanced Sandboxing Capabilities**: Added support for native gVisor (runsc) + sandboxing as well as experimental LXC container sandboxing to provide more + robust and isolated execution environments. +- **Improved Loop Detection & Recovery**: Implemented iterative loop detection + and model feedback mechanisms to prevent the CLI from getting stuck in + repetitive actions. +- **Customizable UI Elements**: You can now configure a custom footer using the + new `/footer` command, and enjoy standardized semantic focus colors for better + history visibility. +- **Extensive Subagent Updates**: Refinements across the tracker visualization + tools, background process logging, and broader fallback support for models in + tool execution scenarios. ## What's Changed -- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in - [#18240](https://github.com/google-gemini/gemini-cli/pull/18240) -- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by - @skeshive in [#18771](https://github.com/google-gemini/gemini-cli/pull/18771) -- chore(release): bump version to 0.30.0-nightly.20260210.a2174751d by +- feat(cli): add chat resume footer on session quit by @lordshashank in + [#20667](https://github.com/google-gemini/gemini-cli/pull/20667) +- Support bold and other styles in svg snapshots by @jacob314 in + [#20937](https://github.com/google-gemini/gemini-cli/pull/20937) +- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in + [#21028](https://github.com/google-gemini/gemini-cli/pull/21028) +- Cleanup old branches. by @jacob314 in + [#19354](https://github.com/google-gemini/gemini-cli/pull/19354) +- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by @gemini-cli-robot in - [#18772](https://github.com/google-gemini/gemini-cli/pull/18772) -- chore: cleanup unused and add unlisted dependencies in packages/core by - @adamfweidman in - [#18762](https://github.com/google-gemini/gemini-cli/pull/18762) -- chore(core): update activate_skill prompt verbiage to be more direct by - @NTaylorMullen in - [#18605](https://github.com/google-gemini/gemini-cli/pull/18605) -- Add autoconfigure memory usage setting to the dialog by @jacob314 in - [#18510](https://github.com/google-gemini/gemini-cli/pull/18510) -- fix(core): prevent race condition in policy persistence by @braddux in - [#18506](https://github.com/google-gemini/gemini-cli/pull/18506) -- fix(evals): prevent false positive in hierarchical memory test by - @Abhijit-2592 in - [#18777](https://github.com/google-gemini/gemini-cli/pull/18777) -- test(evals): mark all `save_memory` evals as `USUALLY_PASSES` due to - unreliability by @jerop in - [#18786](https://github.com/google-gemini/gemini-cli/pull/18786) -- feat(cli): add setting to hide shortcuts hint UI by @LyalinDotCom in - [#18562](https://github.com/google-gemini/gemini-cli/pull/18562) -- feat(core): formalize 5-phase sequential planning workflow by @jerop in - [#18759](https://github.com/google-gemini/gemini-cli/pull/18759) -- Introduce limits for search results. by @gundermanc in - [#18767](https://github.com/google-gemini/gemini-cli/pull/18767) -- fix(cli): allow closing debug console after auto-open via flicker by - @SandyTao520 in - [#18795](https://github.com/google-gemini/gemini-cli/pull/18795) -- feat(masking): enable tool output masking by default by @abhipatel12 in - [#18564](https://github.com/google-gemini/gemini-cli/pull/18564) -- perf(ui): optimize table rendering by memoizing styled characters by @devr0306 - in [#18770](https://github.com/google-gemini/gemini-cli/pull/18770) -- feat: multi-line text answers in ask-user tool by @jackwotherspoon in - [#18741](https://github.com/google-gemini/gemini-cli/pull/18741) -- perf(cli): truncate large debug logs and limit message history by @mattKorwel - in [#18663](https://github.com/google-gemini/gemini-cli/pull/18663) -- fix(core): complete MCP discovery when configured servers are skipped by - @LyalinDotCom in - [#18586](https://github.com/google-gemini/gemini-cli/pull/18586) -- fix(core): cache CLI version to ensure consistency during sessions by - @sehoon38 in [#18793](https://github.com/google-gemini/gemini-cli/pull/18793) -- fix(cli): resolve double rendering in shpool and address vscode lint warnings - by @braddux in - [#18704](https://github.com/google-gemini/gemini-cli/pull/18704) -- feat(plan): document and validate Plan Mode policy overrides by @jerop in - [#18825](https://github.com/google-gemini/gemini-cli/pull/18825) -- Fix pressing any key to exit select mode. by @jacob314 in - [#18421](https://github.com/google-gemini/gemini-cli/pull/18421) -- fix(cli): update F12 behavior to only open drawer if browser fails by - @SandyTao520 in - [#18829](https://github.com/google-gemini/gemini-cli/pull/18829) -- feat(plan): allow skills to be enabled in plan mode by @Adib234 in - [#18817](https://github.com/google-gemini/gemini-cli/pull/18817) -- docs(plan): add documentation for plan mode tools by @jerop in - [#18827](https://github.com/google-gemini/gemini-cli/pull/18827) -- Remove experimental note in extension settings docs by @chrstnb in - [#18822](https://github.com/google-gemini/gemini-cli/pull/18822) -- Update prompt and grep tool definition to limit context size by @gundermanc in - [#18780](https://github.com/google-gemini/gemini-cli/pull/18780) -- docs(plan): add `ask_user` tool documentation by @jerop in - [#18830](https://github.com/google-gemini/gemini-cli/pull/18830) -- Revert unintended credentials exposure by @Adib234 in - [#18840](https://github.com/google-gemini/gemini-cli/pull/18840) -- feat(core): update internal utility models to Gemini 3 by @SandyTao520 in - [#18773](https://github.com/google-gemini/gemini-cli/pull/18773) -- feat(a2a): add value-resolver for auth credential resolution by @adamfweidman - in [#18653](https://github.com/google-gemini/gemini-cli/pull/18653) -- Removed getPlainTextLength by @devr0306 in - [#18848](https://github.com/google-gemini/gemini-cli/pull/18848) -- More grep prompt tweaks by @gundermanc in - [#18846](https://github.com/google-gemini/gemini-cli/pull/18846) -- refactor(cli): Reactive useSettingsStore hook by @psinha40898 in - [#14915](https://github.com/google-gemini/gemini-cli/pull/14915) -- fix(mcp): Ensure that stdio MCP server execution has the `GEMINI_CLI=1` env - variable populated. by @richieforeman in - [#18832](https://github.com/google-gemini/gemini-cli/pull/18832) -- fix(core): improve headless mode detection for flags and query args by @galz10 - in [#18855](https://github.com/google-gemini/gemini-cli/pull/18855) -- refactor(cli): simplify UI and remove legacy inline tool confirmation logic by - @abhipatel12 in - [#18566](https://github.com/google-gemini/gemini-cli/pull/18566) -- feat(cli): deprecate --allowed-tools and excludeTools in favor of policy - engine by @Abhijit-2592 in - [#18508](https://github.com/google-gemini/gemini-cli/pull/18508) -- fix(workflows): improve maintainer detection for automated PR actions by - @bdmorgan in [#18869](https://github.com/google-gemini/gemini-cli/pull/18869) -- refactor(cli): consolidate useToolScheduler and delete legacy implementation - by @abhipatel12 in - [#18567](https://github.com/google-gemini/gemini-cli/pull/18567) -- Update changelog for v0.28.0 and v0.29.0-preview0 by @g-samroberts in - [#18819](https://github.com/google-gemini/gemini-cli/pull/18819) -- fix(core): ensure sub-agents are registered regardless of tools.allowed by - @mattKorwel in - [#18870](https://github.com/google-gemini/gemini-cli/pull/18870) -- Show notification when there's a conflict with an extensions command by - @chrstnb in [#17890](https://github.com/google-gemini/gemini-cli/pull/17890) -- fix(cli): dismiss '?' shortcuts help on hotkeys and active states by - @LyalinDotCom in - [#18583](https://github.com/google-gemini/gemini-cli/pull/18583) -- fix(core): prioritize conditional policy rules and harden Plan Mode by - @Abhijit-2592 in - [#18882](https://github.com/google-gemini/gemini-cli/pull/18882) -- feat(core): refine Plan Mode system prompt for agentic execution by - @NTaylorMullen in - [#18799](https://github.com/google-gemini/gemini-cli/pull/18799) -- feat(plan): create metrics for usage of `AskUser` tool by @Adib234 in - [#18820](https://github.com/google-gemini/gemini-cli/pull/18820) -- feat(cli): support Ctrl-Z suspension by @scidomino in - [#18931](https://github.com/google-gemini/gemini-cli/pull/18931) -- fix(github-actions): use robot PAT for release creation to trigger release - notes by @SandyTao520 in - [#18794](https://github.com/google-gemini/gemini-cli/pull/18794) -- feat: add strict seatbelt profiles and remove unusable closed profiles by - @SandyTao520 in - [#18876](https://github.com/google-gemini/gemini-cli/pull/18876) -- chore: cleanup unused and add unlisted dependencies in packages/a2a-server by - @adamfweidman in - [#18916](https://github.com/google-gemini/gemini-cli/pull/18916) -- fix(plan): isolate plan files per session by @Adib234 in - [#18757](https://github.com/google-gemini/gemini-cli/pull/18757) -- fix: character truncation in raw markdown mode by @jackwotherspoon in - [#18938](https://github.com/google-gemini/gemini-cli/pull/18938) -- feat(cli): prototype clean UI toggle and minimal-mode bleed-through by - @LyalinDotCom in - [#18683](https://github.com/google-gemini/gemini-cli/pull/18683) -- ui(polish) blend background color with theme by @jacob314 in - [#18802](https://github.com/google-gemini/gemini-cli/pull/18802) -- Add generic searchable list to back settings and extensions by @chrstnb in - [#18838](https://github.com/google-gemini/gemini-cli/pull/18838) -- feat(ui): align `AskUser` color scheme with UX spec by @jerop in - [#18943](https://github.com/google-gemini/gemini-cli/pull/18943) -- Hide AskUser tool validation errors from UI (agent self-corrects) by @jerop in - [#18954](https://github.com/google-gemini/gemini-cli/pull/18954) -- bug(cli) fix flicker due to AppContainer continuous initialization by - @jacob314 in [#18958](https://github.com/google-gemini/gemini-cli/pull/18958) -- feat(admin): Add admin controls documentation by @skeshive in - [#18644](https://github.com/google-gemini/gemini-cli/pull/18644) -- feat(cli): disable ctrl-s shortcut outside of alternate buffer mode by - @jacob314 in [#18887](https://github.com/google-gemini/gemini-cli/pull/18887) -- fix(vim): vim support that feels (more) complete by @ppgranger in - [#18755](https://github.com/google-gemini/gemini-cli/pull/18755) -- feat(policy): add --policy flag for user defined policies by @allenhutchison - in [#18500](https://github.com/google-gemini/gemini-cli/pull/18500) -- Update installation guide by @g-samroberts in - [#18823](https://github.com/google-gemini/gemini-cli/pull/18823) -- refactor(core): centralize tool definitions (Group 1: replace, search, grep) - by @aishaneeshah in - [#18944](https://github.com/google-gemini/gemini-cli/pull/18944) -- refactor(cli): finalize event-driven transition and remove interaction bridge - by @abhipatel12 in - [#18569](https://github.com/google-gemini/gemini-cli/pull/18569) -- Fix drag and drop escaping by @scidomino in - [#18965](https://github.com/google-gemini/gemini-cli/pull/18965) -- feat(sdk): initial package bootstrap for SDK by @mbleigh in - [#18861](https://github.com/google-gemini/gemini-cli/pull/18861) -- feat(sdk): implements SessionContext for SDK tool calls by @mbleigh in - [#18862](https://github.com/google-gemini/gemini-cli/pull/18862) -- fix(plan): make question type required in AskUser tool by @Adib234 in - [#18959](https://github.com/google-gemini/gemini-cli/pull/18959) -- fix(core): ensure --yolo does not force headless mode by @NTaylorMullen in - [#18976](https://github.com/google-gemini/gemini-cli/pull/18976) -- refactor(core): adopt `CoreToolCallStatus` enum for type safety by @jerop in - [#18998](https://github.com/google-gemini/gemini-cli/pull/18998) -- Enable in-CLI extension management commands for team by @chrstnb in - [#18957](https://github.com/google-gemini/gemini-cli/pull/18957) -- Adjust lint rules to avoid unnecessary warning. by @scidomino in - [#18970](https://github.com/google-gemini/gemini-cli/pull/18970) -- fix(vscode): resolve unsafe type assertion lint errors by @ehedlund in - [#19006](https://github.com/google-gemini/gemini-cli/pull/19006) -- Remove unnecessary eslint config file by @scidomino in - [#19015](https://github.com/google-gemini/gemini-cli/pull/19015) -- fix(core): Prevent loop detection false positives on lists with long shared - prefixes by @SandyTao520 in - [#18975](https://github.com/google-gemini/gemini-cli/pull/18975) -- feat(core): fallback to chat-base when using unrecognized models for chat by - @SandyTao520 in - [#19016](https://github.com/google-gemini/gemini-cli/pull/19016) -- docs: fix inconsistent commandRegex example in policy engine by @NTaylorMullen - in [#19027](https://github.com/google-gemini/gemini-cli/pull/19027) -- fix(plan): persist the approval mode in UI even when agent is thinking by - @Adib234 in [#18955](https://github.com/google-gemini/gemini-cli/pull/18955) -- feat(sdk): Implement dynamic system instructions by @mbleigh in - [#18863](https://github.com/google-gemini/gemini-cli/pull/18863) -- Docs: Refresh docs to organize and standardize reference materials. by - @jkcinouye in [#18403](https://github.com/google-gemini/gemini-cli/pull/18403) -- fix windows escaping (and broken tests) by @scidomino in - [#19011](https://github.com/google-gemini/gemini-cli/pull/19011) -- refactor: use `CoreToolCallStatus` in the the history data model by @jerop in - [#19033](https://github.com/google-gemini/gemini-cli/pull/19033) -- feat(cleanup): enable 30-day session retention by default by @skeshive in - [#18854](https://github.com/google-gemini/gemini-cli/pull/18854) -- feat(plan): hide plan write and edit operations on plans in Plan Mode by - @jerop in [#19012](https://github.com/google-gemini/gemini-cli/pull/19012) -- bug(ui) fix flicker refreshing background color by @jacob314 in - [#19041](https://github.com/google-gemini/gemini-cli/pull/19041) -- chore: fix dep vulnerabilities by @scidomino in - [#19036](https://github.com/google-gemini/gemini-cli/pull/19036) -- Revamp automated changelog skill by @g-samroberts in - [#18974](https://github.com/google-gemini/gemini-cli/pull/18974) -- feat(sdk): implement support for custom skills by @mbleigh in - [#19031](https://github.com/google-gemini/gemini-cli/pull/19031) -- refactor(core): complete centralization of core tool definitions by + [#21034](https://github.com/google-gemini/gemini-cli/pull/21034) +- feat(ui): standardize semantic focus colors and enhance history visibility by + @keithguerin in + [#20745](https://github.com/google-gemini/gemini-cli/pull/20745) +- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in + [#20928](https://github.com/google-gemini/gemini-cli/pull/20928) +- Add extra safety checks for proto pollution by @jacob314 in + [#20396](https://github.com/google-gemini/gemini-cli/pull/20396) +- feat(core): Add tracker CRUD tools & visualization by @anj-s in + [#19489](https://github.com/google-gemini/gemini-cli/pull/19489) +- Revert "fix(ui): persist expansion in AskUser dialog when navigating options" + by @jacob314 in + [#21042](https://github.com/google-gemini/gemini-cli/pull/21042) +- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in + [#21030](https://github.com/google-gemini/gemini-cli/pull/21030) +- fix: model persistence for all scenarios by @sripasg in + [#21051](https://github.com/google-gemini/gemini-cli/pull/21051) +- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by + @gemini-cli-robot in + [#21054](https://github.com/google-gemini/gemini-cli/pull/21054) +- Consistently guard restarts against concurrent auto updates by @scidomino in + [#21016](https://github.com/google-gemini/gemini-cli/pull/21016) +- Defensive coding to reduce the risk of Maximum update depth errors by + @jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940) +- fix(cli): Polish shell autocomplete rendering to be a little more shell native + feeling. by @jacob314 in + [#20931](https://github.com/google-gemini/gemini-cli/pull/20931) +- Docs: Update plan mode docs by @jkcinouye in + [#19682](https://github.com/google-gemini/gemini-cli/pull/19682) +- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in + [#21050](https://github.com/google-gemini/gemini-cli/pull/21050) +- fix(cli): register extension lifecycle events in DebugProfiler by + @fayerman-source in + [#20101](https://github.com/google-gemini/gemini-cli/pull/20101) +- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in + [#19907](https://github.com/google-gemini/gemini-cli/pull/19907) +- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in + [#19821](https://github.com/google-gemini/gemini-cli/pull/19821) +- Changelog for v0.32.0 by @gemini-cli-robot in + [#21033](https://github.com/google-gemini/gemini-cli/pull/21033) +- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in + [#21058](https://github.com/google-gemini/gemini-cli/pull/21058) +- feat(core): improve @scripts/copy_files.js autocomplete to prioritize + filenames by @sehoon38 in + [#21064](https://github.com/google-gemini/gemini-cli/pull/21064) +- feat(sandbox): add experimental LXC container sandbox support by @h30s in + [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) +- feat(evals): add overall pass rate row to eval nightly summary table by + @gundermanc in + [#20905](https://github.com/google-gemini/gemini-cli/pull/20905) +- feat(telemetry): include language in telemetry and fix accepted lines + computation by @gundermanc in + [#21126](https://github.com/google-gemini/gemini-cli/pull/21126) +- Changelog for v0.32.1 by @gemini-cli-robot in + [#21055](https://github.com/google-gemini/gemini-cli/pull/21055) +- feat(core): add robustness tests, logging, and metrics for CodeAssistServer + SSE parsing by @yunaseoul in + [#21013](https://github.com/google-gemini/gemini-cli/pull/21013) +- feat: add issue assignee workflow by @kartikangiras in + [#21003](https://github.com/google-gemini/gemini-cli/pull/21003) +- fix: improve error message when OAuth succeeds but project ID is required by + @Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070) +- feat(loop-reduction): implement iterative loop detection and model feedback by @aishaneeshah in - [#18991](https://github.com/google-gemini/gemini-cli/pull/18991) -- feat: add /commands reload to refresh custom TOML commands by @korade-krushna - in [#19078](https://github.com/google-gemini/gemini-cli/pull/19078) -- fix(cli): wrap terminal capability queries in hidden sequence by @srithreepo - in [#19080](https://github.com/google-gemini/gemini-cli/pull/19080) -- fix(workflows): fix GitHub App token permissions for maintainer detection by - @bdmorgan in [#19139](https://github.com/google-gemini/gemini-cli/pull/19139) -- test: fix hook integration test flakiness on Windows CI by @NTaylorMullen in - [#18665](https://github.com/google-gemini/gemini-cli/pull/18665) -- fix(core): Encourage non-interactive flags for scaffolding commands by + [#20763](https://github.com/google-gemini/gemini-cli/pull/20763) +- chore(github): require prompt approvers for agent prompt files by @gundermanc + in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896) +- Docs: Create tools reference by @jkcinouye in + [#19470](https://github.com/google-gemini/gemini-cli/pull/19470) +- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions + by @spencer426 in + [#21045](https://github.com/google-gemini/gemini-cli/pull/21045) +- chore(cli): enable deprecated settings removal by default by @yashodipmore in + [#20682](https://github.com/google-gemini/gemini-cli/pull/20682) +- feat(core): Disable fast ack helper for hints. by @joshualitt in + [#21011](https://github.com/google-gemini/gemini-cli/pull/21011) +- fix(ui): suppress redundant failure note when tool error note is shown by @NTaylorMullen in - [#18804](https://github.com/google-gemini/gemini-cli/pull/18804) -- fix(core): propagate User-Agent header to setup-phase CodeAssist API calls by - @gsquared94 in - [#19182](https://github.com/google-gemini/gemini-cli/pull/19182) -- docs: document .agents/skills alias and discovery precedence by @kevmoo in - [#19166](https://github.com/google-gemini/gemini-cli/pull/19166) -- feat(cli): add loading state to new agents notification by @sehoon38 in - [#19190](https://github.com/google-gemini/gemini-cli/pull/19190) -- Add base branch to workflow. by @g-samroberts in - [#19189](https://github.com/google-gemini/gemini-cli/pull/19189) -- feat(cli): handle invalid model names in useQuotaAndFallback by @sehoon38 in - [#19222](https://github.com/google-gemini/gemini-cli/pull/19222) -- docs: custom themes in extensions by @jackwotherspoon in - [#19219](https://github.com/google-gemini/gemini-cli/pull/19219) -- Disable workspace settings when starting GCLI in the home directory. by - @kevinjwang1 in - [#19034](https://github.com/google-gemini/gemini-cli/pull/19034) -- feat(cli): refactor model command to support set and manage subcommands by - @sehoon38 in [#19221](https://github.com/google-gemini/gemini-cli/pull/19221) -- Add refresh/reload aliases to slash command subcommands by @korade-krushna in - [#19218](https://github.com/google-gemini/gemini-cli/pull/19218) -- refactor: consolidate development rules and add cli guidelines by @jacob314 in - [#19214](https://github.com/google-gemini/gemini-cli/pull/19214) -- chore(ui): remove outdated tip about model routing by @sehoon38 in - [#19226](https://github.com/google-gemini/gemini-cli/pull/19226) -- feat(core): support custom reasoning models by default by @NTaylorMullen in - [#19227](https://github.com/google-gemini/gemini-cli/pull/19227) -- Add Solarized Dark and Solarized Light themes by @rmedranollamas in - [#19064](https://github.com/google-gemini/gemini-cli/pull/19064) -- fix(telemetry): replace JSON.stringify with safeJsonStringify in file - exporters by @gsquared94 in - [#19244](https://github.com/google-gemini/gemini-cli/pull/19244) -- feat(telemetry): add keychain availability and token storage metrics by + [#21078](https://github.com/google-gemini/gemini-cli/pull/21078) +- docs: document planning workflows with Conductor example by @jerop in + [#21166](https://github.com/google-gemini/gemini-cli/pull/21166) +- feat(release): ship esbuild bundle in npm package by @genneth in + [#19171](https://github.com/google-gemini/gemini-cli/pull/19171) +- fix(extensions): preserve symlinks in extension source path while enforcing + folder trust by @galz10 in + [#20867](https://github.com/google-gemini/gemini-cli/pull/20867) +- fix(cli): defer tool exclusions to policy engine in non-interactive mode by + @EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639) +- fix(ui): removed double padding on rendered content by @devr0306 in + [#21029](https://github.com/google-gemini/gemini-cli/pull/21029) +- fix(core): truncate excessively long lines in grep search output by + @gundermanc in + [#21147](https://github.com/google-gemini/gemini-cli/pull/21147) +- feat: add custom footer configuration via `/footer` by @jackwotherspoon in + [#19001](https://github.com/google-gemini/gemini-cli/pull/19001) +- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in + [#19608](https://github.com/google-gemini/gemini-cli/pull/19608) +- refactor(cli): categorize built-in themes into dark/ and light/ directories by + @JayadityaGit in + [#18634](https://github.com/google-gemini/gemini-cli/pull/18634) +- fix(core): explicitly allow codebase_investigator and cli_help in read-only + mode by @Adib234 in + [#21157](https://github.com/google-gemini/gemini-cli/pull/21157) +- test: add browser agent integration tests by @kunal-10-cloud in + [#21151](https://github.com/google-gemini/gemini-cli/pull/21151) +- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in + [#21136](https://github.com/google-gemini/gemini-cli/pull/21136) +- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by + @SandyTao520 in + [#20895](https://github.com/google-gemini/gemini-cli/pull/20895) +- fix(ui): add partial output to cancelled shell UI by @devr0306 in + [#21178](https://github.com/google-gemini/gemini-cli/pull/21178) +- fix(cli): replace hardcoded keybinding strings with dynamic formatters by + @scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159) +- DOCS: Update quota and pricing page by @g-samroberts in + [#21194](https://github.com/google-gemini/gemini-cli/pull/21194) +- feat(telemetry): implement Clearcut logging for startup statistics by + @yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172) +- feat(triage): add area/documentation to issue triage by @g-samroberts in + [#21222](https://github.com/google-gemini/gemini-cli/pull/21222) +- Fix so shell calls are formatted by @jacob314 in + [#21237](https://github.com/google-gemini/gemini-cli/pull/21237) +- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in + [#21062](https://github.com/google-gemini/gemini-cli/pull/21062) +- docs: use absolute paths for internal links in plan-mode.md by @jerop in + [#21299](https://github.com/google-gemini/gemini-cli/pull/21299) +- fix(core): prevent unhandled AbortError crash during stream loop detection by + @7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123) +- fix:reorder env var redaction checks to scan values first by @kartikangiras in + [#21059](https://github.com/google-gemini/gemini-cli/pull/21059) +- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences + by @skeshive in + [#21171](https://github.com/google-gemini/gemini-cli/pull/21171) +- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38 + in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283) +- test(core): improve testing for API request/response parsing by @sehoon38 in + [#21227](https://github.com/google-gemini/gemini-cli/pull/21227) +- docs(links): update docs-writer skill and fix broken link by @g-samroberts in + [#21314](https://github.com/google-gemini/gemini-cli/pull/21314) +- Fix code colorizer ansi escape bug. by @jacob314 in + [#21321](https://github.com/google-gemini/gemini-cli/pull/21321) +- remove wildcard behavior on keybindings by @scidomino in + [#21315](https://github.com/google-gemini/gemini-cli/pull/21315) +- feat(acp): Add support for AI Gateway auth by @skeshive in + [#21305](https://github.com/google-gemini/gemini-cli/pull/21305) +- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in + [#21175](https://github.com/google-gemini/gemini-cli/pull/21175) +- feat (core): Implement tracker related SI changes by @anj-s in + [#19964](https://github.com/google-gemini/gemini-cli/pull/19964) +- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in + [#21333](https://github.com/google-gemini/gemini-cli/pull/21333) +- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in + [#21347](https://github.com/google-gemini/gemini-cli/pull/21347) +- docs: format release times as HH:MM UTC by @pavan-sh in + [#20726](https://github.com/google-gemini/gemini-cli/pull/20726) +- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in + [#21319](https://github.com/google-gemini/gemini-cli/pull/21319) +- docs: fix incorrect relative links to command reference by @kanywst in + [#20964](https://github.com/google-gemini/gemini-cli/pull/20964) +- documentiong ensures ripgrep by @Jatin24062005 in + [#21298](https://github.com/google-gemini/gemini-cli/pull/21298) +- fix(core): handle AbortError thrown during processTurn by @MumuTW in + [#21296](https://github.com/google-gemini/gemini-cli/pull/21296) +- docs(cli): clarify ! command output visibility in shell commands tutorial by + @MohammedADev in + [#21041](https://github.com/google-gemini/gemini-cli/pull/21041) +- fix: logic for task tracker strategy and remove tracker tools by @anj-s in + [#21355](https://github.com/google-gemini/gemini-cli/pull/21355) +- fix(partUtils): display media type and size for inline data parts by @Aboudjem + in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358) +- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in + [#20750](https://github.com/google-gemini/gemini-cli/pull/20750) +- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by + @Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439) +- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive + filesystems (#19904) by @Nixxx19 in + [#19915](https://github.com/google-gemini/gemini-cli/pull/19915) +- feat(core): add concurrency safety guidance for subagent delegation (#17753) + by @abhipatel12 in + [#21278](https://github.com/google-gemini/gemini-cli/pull/21278) +- feat(ui): dynamically generate all keybinding hints by @scidomino in + [#21346](https://github.com/google-gemini/gemini-cli/pull/21346) +- feat(core): implement unified KeychainService and migrate token storage by + @ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344) +- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in + [#21429](https://github.com/google-gemini/gemini-cli/pull/21429) +- fix(plan): keep approved plan during chat compression by @ruomengz in + [#21284](https://github.com/google-gemini/gemini-cli/pull/21284) +- feat(core): implement generic CacheService and optimize setupUser by @sehoon38 + in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374) +- Update quota and pricing documentation with subscription tiers by @srithreepo + in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351) +- fix(core): append correct OTLP paths for HTTP exporters by + @sebastien-prudhomme in + [#16836](https://github.com/google-gemini/gemini-cli/pull/16836) +- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in + [#21354](https://github.com/google-gemini/gemini-cli/pull/21354) +- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in + [#20979](https://github.com/google-gemini/gemini-cli/pull/20979) +- refactor(core): standardize MCP tool naming to mcp\_ FQN format by @abhipatel12 in - [#18971](https://github.com/google-gemini/gemini-cli/pull/18971) -- feat(cli): update approval mode cycle order by @jerop in - [#19254](https://github.com/google-gemini/gemini-cli/pull/19254) -- refactor(cli): code review cleanup fix for tab+tab by @jacob314 in - [#18967](https://github.com/google-gemini/gemini-cli/pull/18967) -- feat(plan): support project exploration without planning when in plan mode by - @Adib234 in [#18992](https://github.com/google-gemini/gemini-cli/pull/18992) -- feat: add role-specific statistics to telemetry and UI (cont. #15234) by - @yunaseoul in [#18824](https://github.com/google-gemini/gemini-cli/pull/18824) -- feat(cli): remove Plan Mode from rotation when actively working by @jerop in - [#19262](https://github.com/google-gemini/gemini-cli/pull/19262) -- Fix side breakage where anchors don't work in slugs. by @g-samroberts in - [#19261](https://github.com/google-gemini/gemini-cli/pull/19261) -- feat(config): add setting to make directory tree context configurable by - @kevin-ramdass in - [#19053](https://github.com/google-gemini/gemini-cli/pull/19053) -- fix(acp): Wait for mcp initialization in acp (#18893) by @Mervap in - [#18894](https://github.com/google-gemini/gemini-cli/pull/18894) -- docs: format UTC times in releases doc by @pavan-sh in - [#18169](https://github.com/google-gemini/gemini-cli/pull/18169) -- Docs: Clarify extensions documentation. by @jkcinouye in - [#19277](https://github.com/google-gemini/gemini-cli/pull/19277) -- refactor(core): modularize tool definitions by model family by @aishaneeshah - in [#19269](https://github.com/google-gemini/gemini-cli/pull/19269) -- fix(paths): Add cross-platform path normalization by @spencer426 in - [#18939](https://github.com/google-gemini/gemini-cli/pull/18939) -- feat(core): experimental in-progress steering hints (1 of 3) by @joshualitt in - [#19008](https://github.com/google-gemini/gemini-cli/pull/19008) -- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch - version v0.30.0-preview.0 and create version 0.30.0-preview.1 by + [#21425](https://github.com/google-gemini/gemini-cli/pull/21425) +- feat(cli): hide gemma settings from display and mark as experimental by + @abhipatel12 in + [#21471](https://github.com/google-gemini/gemini-cli/pull/21471) +- feat(skills): refine string-reviewer guidelines and description by @clocky in + [#20368](https://github.com/google-gemini/gemini-cli/pull/20368) +- fix(core): whitelist TERM and COLORTERM in environment sanitization by + @deadsmash07 in + [#20514](https://github.com/google-gemini/gemini-cli/pull/20514) +- fix(billing): fix overage strategy lifecycle and settings integration by + @gsquared94 in + [#21236](https://github.com/google-gemini/gemini-cli/pull/21236) +- fix: expand paste placeholders in TextInput on submit by @Jefftree in + [#19946](https://github.com/google-gemini/gemini-cli/pull/19946) +- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by + @SandyTao520 in + [#21502](https://github.com/google-gemini/gemini-cli/pull/21502) +- feat(cli): overhaul thinking UI by @keithguerin in + [#18725](https://github.com/google-gemini/gemini-cli/pull/18725) +- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by + @jwhelangoog in + [#21474](https://github.com/google-gemini/gemini-cli/pull/21474) +- fix(cli): correct shell height reporting by @jacob314 in + [#21492](https://github.com/google-gemini/gemini-cli/pull/21492) +- Make test suite pass when the GEMINI_SYSTEM_MD env variable or + GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in + [#21480](https://github.com/google-gemini/gemini-cli/pull/21480) +- Disallow underspecified types by @gundermanc in + [#21485](https://github.com/google-gemini/gemini-cli/pull/21485) +- refactor(cli): standardize on 'reload' verb for all components by @keithguerin + in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654) +- feat(cli): Invert quota language to 'percent used' by @keithguerin in + [#20100](https://github.com/google-gemini/gemini-cli/pull/20100) +- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye + in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163) +- Code review comments as a pr by @jacob314 in + [#21209](https://github.com/google-gemini/gemini-cli/pull/21209) +- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in + [#20256](https://github.com/google-gemini/gemini-cli/pull/20256) +- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by + @Gyanranjan-Priyam in + [#21665](https://github.com/google-gemini/gemini-cli/pull/21665) +- fix(core): display actual graph output in tracker_visualize tool by @anj-s in + [#21455](https://github.com/google-gemini/gemini-cli/pull/21455) +- fix(core): sanitize SSE-corrupted JSON and domain strings in error + classification by @gsquared94 in + [#21702](https://github.com/google-gemini/gemini-cli/pull/21702) +- Docs: Make documentation links relative by @diodesign in + [#21490](https://github.com/google-gemini/gemini-cli/pull/21490) +- feat(cli): expose /tools desc as explicit subcommand for discoverability by + @aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241) +- feat(cli): add /compact alias for /compress command by @jackwotherspoon in + [#21711](https://github.com/google-gemini/gemini-cli/pull/21711) +- feat(plan): enable Plan Mode by default by @jerop in + [#21713](https://github.com/google-gemini/gemini-cli/pull/21713) +- feat(core): Introduce `AgentLoopContext`. by @joshualitt in + [#21198](https://github.com/google-gemini/gemini-cli/pull/21198) +- fix(core): resolve symlinks for non-existent paths during validation by + @Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487) +- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in + [#21428](https://github.com/google-gemini/gemini-cli/pull/21428) +- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38 + in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520) +- feat(cli): implement /upgrade command by @sehoon38 in + [#21511](https://github.com/google-gemini/gemini-cli/pull/21511) +- Feat/browser agent progress emission by @kunal-10-cloud in + [#21218](https://github.com/google-gemini/gemini-cli/pull/21218) +- fix(settings): display objects as JSON instead of [object Object] by + @Zheyuan-Lin in + [#21458](https://github.com/google-gemini/gemini-cli/pull/21458) +- Unmarshall update by @DavidAPierce in + [#21721](https://github.com/google-gemini/gemini-cli/pull/21721) +- Update mcp's list function to check for disablement. by @DavidAPierce in + [#21148](https://github.com/google-gemini/gemini-cli/pull/21148) +- robustness(core): static checks to validate history is immutable by @jacob314 + in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228) +- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in + [#21206](https://github.com/google-gemini/gemini-cli/pull/21206) +- feat(security): implement robust IP validation and safeFetch foundation by + @alisa-alisa in + [#21401](https://github.com/google-gemini/gemini-cli/pull/21401) +- feat(core): improve subagent result display by @joshualitt in + [#20378](https://github.com/google-gemini/gemini-cli/pull/20378) +- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in + [#20902](https://github.com/google-gemini/gemini-cli/pull/20902) +- feat(policy): support subagent-specific policies in TOML by @akh64bit in + [#21431](https://github.com/google-gemini/gemini-cli/pull/21431) +- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in + [#21748](https://github.com/google-gemini/gemini-cli/pull/21748) +- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in + [#21750](https://github.com/google-gemini/gemini-cli/pull/21750) +- fix(docs): fix headless mode docs by @ame2en in + [#21287](https://github.com/google-gemini/gemini-cli/pull/21287) +- feat/redesign header compact by @jacob314 in + [#20922](https://github.com/google-gemini/gemini-cli/pull/20922) +- refactor: migrate to useKeyMatchers hook by @scidomino in + [#21753](https://github.com/google-gemini/gemini-cli/pull/21753) +- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by + @sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521) +- fix(core): resolve Windows line ending and path separation bugs across CLI by + @muhammadusman586 in + [#21068](https://github.com/google-gemini/gemini-cli/pull/21068) +- docs: fix heading formatting in commands.md and phrasing in tools-api.md by + @campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679) +- refactor(ui): unify keybinding infrastructure and support string + initialization by @scidomino in + [#21776](https://github.com/google-gemini/gemini-cli/pull/21776) +- Add support for updating extension sources and names by @chrstnb in + [#21715](https://github.com/google-gemini/gemini-cli/pull/21715) +- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed + in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376) +- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy + in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693) +- fix(docs): update theme screenshots and add missing themes by @ashmod in + [#20689](https://github.com/google-gemini/gemini-cli/pull/20689) +- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in + [#21796](https://github.com/google-gemini/gemini-cli/pull/21796) +- build(release): restrict npm bundling to non-stable tags by @sehoon38 in + [#21821](https://github.com/google-gemini/gemini-cli/pull/21821) +- fix(core): override toolRegistry property for sub-agent schedulers by + @gsquared94 in + [#21766](https://github.com/google-gemini/gemini-cli/pull/21766) +- fix(cli): make footer items equally spaced by @jacob314 in + [#21843](https://github.com/google-gemini/gemini-cli/pull/21843) +- docs: clarify global policy rules application in plan mode by @jerop in + [#21864](https://github.com/google-gemini/gemini-cli/pull/21864) +- fix(core): ensure correct flash model steering in plan mode implementation + phase by @jerop in + [#21871](https://github.com/google-gemini/gemini-cli/pull/21871) +- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in + [#21875](https://github.com/google-gemini/gemini-cli/pull/21875) +- refactor(core): improve API response error logging when retry by @yunaseoul in + [#21784](https://github.com/google-gemini/gemini-cli/pull/21784) +- fix(ui): handle headless execution in credits and upgrade dialogs by + @gsquared94 in + [#21850](https://github.com/google-gemini/gemini-cli/pull/21850) +- fix(core): treat retryable errors with >5 min delay as terminal quota errors + by @gsquared94 in + [#21881](https://github.com/google-gemini/gemini-cli/pull/21881) +- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub + Actions by @cocosheng-g in + [#21129](https://github.com/google-gemini/gemini-cli/pull/21129) +- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by + @SandyTao520 in + [#21496](https://github.com/google-gemini/gemini-cli/pull/21496) +- feat(cli): give visibility to /tools list command in the TUI and follow the + subcommand pattern of other commands by @JayadityaGit in + [#21213](https://github.com/google-gemini/gemini-cli/pull/21213) +- Handle dirty worktrees better and warn about running scripts/review.sh on + untrusted code. by @jacob314 in + [#21791](https://github.com/google-gemini/gemini-cli/pull/21791) +- feat(policy): support auto-add to policy by default and scoped persistence by + @spencer426 in + [#20361](https://github.com/google-gemini/gemini-cli/pull/20361) +- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21 + in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863) +- fix(release): Improve Patch Release Workflow Comments: Clearer Approval + Guidance by @jerop in + [#21894](https://github.com/google-gemini/gemini-cli/pull/21894) +- docs: clarify telemetry setup and comprehensive data map by @jerop in + [#21879](https://github.com/google-gemini/gemini-cli/pull/21879) +- feat(core): add per-model token usage to stream-json output by @yongruilin in + [#21839](https://github.com/google-gemini/gemini-cli/pull/21839) +- docs: remove experimental badge from plan mode in sidebar by @jerop in + [#21906](https://github.com/google-gemini/gemini-cli/pull/21906) +- fix(cli): prevent race condition in loop detection retry by @skyvanguard in + [#17916](https://github.com/google-gemini/gemini-cli/pull/17916) +- Add behavioral evals for tracker by @anj-s in + [#20069](https://github.com/google-gemini/gemini-cli/pull/20069) +- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in + [#20892](https://github.com/google-gemini/gemini-cli/pull/20892) +- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in + [#21664](https://github.com/google-gemini/gemini-cli/pull/21664) +- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in + [#21852](https://github.com/google-gemini/gemini-cli/pull/21852) +- make command names consistent by @scidomino in + [#21907](https://github.com/google-gemini/gemini-cli/pull/21907) +- refactor: remove agent_card_requires_auth config flag by @adamfweidman in + [#21914](https://github.com/google-gemini/gemini-cli/pull/21914) +- feat(a2a): implement standardized normalization and streaming reassembly by + @alisa-alisa in + [#21402](https://github.com/google-gemini/gemini-cli/pull/21402) +- feat(cli): enable skill activation via slash commands by @NTaylorMullen in + [#21758](https://github.com/google-gemini/gemini-cli/pull/21758) +- docs(cli): mention per-model token usage in stream-json result event by + @yongruilin in + [#21908](https://github.com/google-gemini/gemini-cli/pull/21908) +- fix(plan): prevent plan truncation in approval dialog by supporting + unconstrained heights by @Adib234 in + [#21037](https://github.com/google-gemini/gemini-cli/pull/21037) +- feat(a2a): switch from callback-based to event-driven tool scheduler by + @cocosheng-g in + [#21467](https://github.com/google-gemini/gemini-cli/pull/21467) +- feat(voice): implement speech-friendly response formatter by @ayush31010 in + [#20989](https://github.com/google-gemini/gemini-cli/pull/20989) +- feat: add pulsating blue border automation overlay to browser agent by + @kunal-10-cloud in + [#21173](https://github.com/google-gemini/gemini-cli/pull/21173) +- Add extensionRegistryURI setting to change where the registry is read from by + @kevinjwang1 in + [#20463](https://github.com/google-gemini/gemini-cli/pull/20463) +- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in + [#21884](https://github.com/google-gemini/gemini-cli/pull/21884) +- fix: prevent hangs in non-interactive mode and improve agent guidance by + @cocosheng-g in + [#20893](https://github.com/google-gemini/gemini-cli/pull/20893) +- Add ExtensionDetails dialog and support install by @chrstnb in + [#20845](https://github.com/google-gemini/gemini-cli/pull/20845) +- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by @gemini-cli-robot in - [#19490](https://github.com/google-gemini/gemini-cli/pull/19490) -- fix(patch): cherry-pick c43500c to release/v0.30.0-preview.1-pr-19502 to patch - version v0.30.0-preview.1 and create version 0.30.0-preview.2 by + [#21816](https://github.com/google-gemini/gemini-cli/pull/21816) +- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in + [#21927](https://github.com/google-gemini/gemini-cli/pull/21927) +- fix(cli): stabilize prompt layout to prevent jumping when typing by + @NTaylorMullen in + [#21081](https://github.com/google-gemini/gemini-cli/pull/21081) +- fix: preserve prompt text when cancelling streaming by @Nixxx19 in + [#21103](https://github.com/google-gemini/gemini-cli/pull/21103) +- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in + [#20307](https://github.com/google-gemini/gemini-cli/pull/20307) +- feat: implement background process logging and cleanup by @galz10 in + [#21189](https://github.com/google-gemini/gemini-cli/pull/21189) +- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in + [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) +- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 + [CONFLICTS] by @gemini-cli-robot in + [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) +- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch + version v0.34.0-preview.1 and create version 0.34.0-preview.2 by @gemini-cli-robot in - [#19521](https://github.com/google-gemini/gemini-cli/pull/19521) -- fix(patch): cherry-pick aa9163d to release/v0.30.0-preview.3-pr-19991 to patch - version v0.30.0-preview.3 and create version 0.30.0-preview.4 by + [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) +- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch + version v0.34.0-preview.2 and create version 0.34.0-preview.3 by @gemini-cli-robot in - [#20040](https://github.com/google-gemini/gemini-cli/pull/20040) -- fix(patch): cherry-pick 2c1d6f8 to release/v0.30.0-preview.4-pr-19369 to patch - version v0.30.0-preview.4 and create version 0.30.0-preview.5 by + [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) +- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch + version v0.34.0-preview.3 and create version 0.34.0-preview.4 by @gemini-cli-robot in - [#20086](https://github.com/google-gemini/gemini-cli/pull/20086) -- fix(patch): cherry-pick d96bd05 to release/v0.30.0-preview.5-pr-19867 to patch - version v0.30.0-preview.5 and create version 0.30.0-preview.6 by - @gemini-cli-robot in - [#20112](https://github.com/google-gemini/gemini-cli/pull/20112) + [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.0 +https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 588573a37c..39e1e0a2ed 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.31.0-preview.0 +# Preview release: v0.35.0-preview.2 -Released: February 25, 2026 +Released: March 19, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -13,400 +13,368 @@ npm install -g @google/gemini-cli@preview ## Highlights -- **Plan Mode Enhancements**: Numerous additions including automatic model - switching, custom storage directory configuration, message injection upon - manual exit, enforcement of read-only constraints, and centralized tool - visibility in the policy engine. -- **Policy Engine Updates**: Project-level policy support added, alongside MCP - server wildcard support, tool annotation propagation and matching, and - workspace-level "Always Allow" persistence. -- **MCP Integration Improvements**: Better integration through support for MCP - progress updates with input validation and throttling, environment variable - expansion for servers, and full details expansion on tool approval. -- **CLI & Core UX Enhancements**: Several UI and quality-of-life updates such as - Alt+D for forward word deletion, macOS run-event notifications, enhanced - folder trust configurations with security warnings, improved startup warnings, - and a new experimental browser agent. -- **Security & Stability**: Introduced the Conseca framework, deceptive URL and - Unicode character detection, stricter access checks, rate limits on web fetch, - and resolved multiple dependency vulnerabilities. +- **Subagents & Architecture Enhancements**: Enabled subagents and laid the + foundation for subagent tool isolation. Added proxy routing support for remote + A2A subagents and integrated `SandboxManager` to sandbox all process-spawning + tools. +- **CLI & UI Improvements**: Introduced customizable keyboard shortcuts and + support for literal character keybindings. Added missing vim mode motions and + CJK input support. Enabled code splitting and deferred UI loading for improved + performance. +- **Context & Tools Optimization**: JIT context loading is now enabled by + default with deduplication for project memory. Introduced a model-driven + parallel tool scheduler and allowed safe tools to execute concurrently. +- **Security & Extensions**: Implemented cryptographic integrity verification + for extension updates and added a `disableAlwaysAllow` setting to prevent + auto-approvals for enhanced security. +- **Plan Mode & Web Fetch Updates**: Added an 'All the above' option for + multi-select AskUser questions in Plan Mode. Rolled out Stage 1 and Stage 2 + security and consistency improvements for the `web_fetch` tool. ## What's Changed -- Use ranged reads and limited searches and fuzzy editing improvements by - @gundermanc in - [#19240](https://github.com/google-gemini/gemini-cli/pull/19240) -- Fix bottom border color by @jacob314 in - [#19266](https://github.com/google-gemini/gemini-cli/pull/19266) -- Release note generator fix by @g-samroberts in - [#19363](https://github.com/google-gemini/gemini-cli/pull/19363) -- test(evals): add behavioral tests for tool output masking by @NTaylorMullen in - [#19172](https://github.com/google-gemini/gemini-cli/pull/19172) -- docs: clarify preflight instructions in GEMINI.md by @NTaylorMullen in - [#19377](https://github.com/google-gemini/gemini-cli/pull/19377) -- feat(cli): add gemini --resume hint on exit by @Mag1ck in - [#16285](https://github.com/google-gemini/gemini-cli/pull/16285) -- fix: optimize height calculations for ask_user dialog by @jackwotherspoon in - [#19017](https://github.com/google-gemini/gemini-cli/pull/19017) -- feat(cli): add Alt+D for forward word deletion by @scidomino in - [#19300](https://github.com/google-gemini/gemini-cli/pull/19300) -- Disable failing eval test by @chrstnb in - [#19455](https://github.com/google-gemini/gemini-cli/pull/19455) -- fix(cli): support legacy onConfirm callback in ToolActionsContext by - @SandyTao520 in - [#19369](https://github.com/google-gemini/gemini-cli/pull/19369) -- chore(deps): bump tar from 7.5.7 to 7.5.8 by dependabot[bot] in - [#19367](https://github.com/google-gemini/gemini-cli/pull/19367) -- fix(plan): allow safe fallback when experiment setting for plan is not enabled - but approval mode at startup is plan by @Adib234 in - [#19439](https://github.com/google-gemini/gemini-cli/pull/19439) -- Add explicit color-convert dependency by @chrstnb in - [#19460](https://github.com/google-gemini/gemini-cli/pull/19460) -- feat(devtools): migrate devtools package into monorepo by @SandyTao520 in - [#18936](https://github.com/google-gemini/gemini-cli/pull/18936) -- fix(core): clarify plan mode constraints and exit mechanism by @jerop in - [#19438](https://github.com/google-gemini/gemini-cli/pull/19438) -- feat(cli): add macOS run-event notifications (interactive only) by - @LyalinDotCom in - [#19056](https://github.com/google-gemini/gemini-cli/pull/19056) -- Changelog for v0.29.0 by @gemini-cli-robot in - [#19361](https://github.com/google-gemini/gemini-cli/pull/19361) -- fix(ui): preventing empty history items from being added by @devr0306 in - [#19014](https://github.com/google-gemini/gemini-cli/pull/19014) -- Changelog for v0.30.0-preview.0 by @gemini-cli-robot in - [#19364](https://github.com/google-gemini/gemini-cli/pull/19364) -- feat(core): add support for MCP progress updates by @NTaylorMullen in - [#19046](https://github.com/google-gemini/gemini-cli/pull/19046) -- fix(core): ensure directory exists before writing conversation file by - @godwiniheuwa in - [#18429](https://github.com/google-gemini/gemini-cli/pull/18429) -- fix(ui): move margin from top to bottom in ToolGroupMessage by @imadraude in - [#17198](https://github.com/google-gemini/gemini-cli/pull/17198) -- fix(cli): treat unknown slash commands as regular input instead of showing - error by @skyvanguard in - [#17393](https://github.com/google-gemini/gemini-cli/pull/17393) -- feat(core): experimental in-progress steering hints (2 of 2) by @joshualitt in - [#19307](https://github.com/google-gemini/gemini-cli/pull/19307) -- docs(plan): add documentation for plan mode command by @Adib234 in - [#19467](https://github.com/google-gemini/gemini-cli/pull/19467) -- fix(core): ripgrep fails when pattern looks like ripgrep flag by @syvb in - [#18858](https://github.com/google-gemini/gemini-cli/pull/18858) -- fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling by - @NTaylorMullen in - [#19451](https://github.com/google-gemini/gemini-cli/pull/19451) -- use issuer instead of authorization_endpoint for oauth discovery by - @garrettsparks in - [#17332](https://github.com/google-gemini/gemini-cli/pull/17332) -- feat(cli): include `/dir add` directories in @ autocomplete suggestions by - @jasmeetsb in [#19246](https://github.com/google-gemini/gemini-cli/pull/19246) -- feat(admin): Admin settings should only apply if adminControlsApplicable = - true and fetch errors should be fatal by @skeshive in - [#19453](https://github.com/google-gemini/gemini-cli/pull/19453) -- Format strict-development-rules command by @g-samroberts in - [#19484](https://github.com/google-gemini/gemini-cli/pull/19484) -- feat(core): centralize compatibility checks and add TrueColor detection by +- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch + version v0.35.0-preview.1 and create version 0.35.0-preview.2 by + @gemini-cli-robot in + [#23134](https://github.com/google-gemini/gemini-cli/pull/23134) +- feat(cli): customizable keyboard shortcuts by @scidomino in + [#21945](https://github.com/google-gemini/gemini-cli/pull/21945) +- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in + [#21944](https://github.com/google-gemini/gemini-cli/pull/21944) +- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by + @gemini-cli-robot in + [#21966](https://github.com/google-gemini/gemini-cli/pull/21966) +- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in + [#21955](https://github.com/google-gemini/gemini-cli/pull/21955) +- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) + by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932) +- Feat/retry fetch notifications by @aishaneeshah in + [#21813](https://github.com/google-gemini/gemini-cli/pull/21813) +- fix(core): remove OAuth check from handleFallback and clean up stray file by + @sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962) +- feat(cli): support literal character keybindings and extended Kitty protocol + keys by @scidomino in + [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) +- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in + [#21973](https://github.com/google-gemini/gemini-cli/pull/21973) +- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in + [#19941](https://github.com/google-gemini/gemini-cli/pull/19941) +- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by + @nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933) +- docs(cli): add custom keybinding documentation by @scidomino in + [#21980](https://github.com/google-gemini/gemini-cli/pull/21980) +- docs: fix misleading YOLO mode description in defaultApprovalMode by + @Gyanranjan-Priyam in + [#21878](https://github.com/google-gemini/gemini-cli/pull/21878) +- fix: clean up /clear and /resume by @jackwotherspoon in + [#22007](https://github.com/google-gemini/gemini-cli/pull/22007) +- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax + in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124) +- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul + in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931) +- feat(cli): support removing keybindings via '-' prefix by @scidomino in + [#22042](https://github.com/google-gemini/gemini-cli/pull/22042) +- feat(policy): add --admin-policy flag for supplemental admin policies by + @galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360) +- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in + [#22040](https://github.com/google-gemini/gemini-cli/pull/22040) +- perf(core): parallelize user quota and experiments fetching in refreshAuth by + @sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648) +- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in + [#21965](https://github.com/google-gemini/gemini-cli/pull/21965) +- Changelog for v0.33.0 by @gemini-cli-robot in + [#21967](https://github.com/google-gemini/gemini-cli/pull/21967) +- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in + [#21984](https://github.com/google-gemini/gemini-cli/pull/21984) +- feat(core): include initiationMethod in conversation interaction telemetry by + @yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054) +- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in + [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) +- fix(core): enable numerical routing for api key users by @sehoon38 in + [#21977](https://github.com/google-gemini/gemini-cli/pull/21977) +- feat(telemetry): implement retry attempt telemetry for network related retries + by @aishaneeshah in + [#22027](https://github.com/google-gemini/gemini-cli/pull/22027) +- fix(policy): remove unnecessary escapeRegex from pattern builders by @spencer426 in - [#19478](https://github.com/google-gemini/gemini-cli/pull/19478) -- Remove unused files and update index and sidebar. by @g-samroberts in - [#19479](https://github.com/google-gemini/gemini-cli/pull/19479) -- Migrate core render util to use xterm.js as part of the rendering loop. by - @jacob314 in [#19044](https://github.com/google-gemini/gemini-cli/pull/19044) -- Changelog for v0.30.0-preview.1 by @gemini-cli-robot in - [#19496](https://github.com/google-gemini/gemini-cli/pull/19496) -- build: replace deprecated built-in punycode with userland package by @jacob314 - in [#19502](https://github.com/google-gemini/gemini-cli/pull/19502) -- Speculative fixes to try to fix react error. by @jacob314 in - [#19508](https://github.com/google-gemini/gemini-cli/pull/19508) -- fix spacing by @jacob314 in - [#19494](https://github.com/google-gemini/gemini-cli/pull/19494) -- fix(core): ensure user rejections update tool outcome for telemetry by - @abhiasap in [#18982](https://github.com/google-gemini/gemini-cli/pull/18982) -- fix(acp): Initialize config (#18897) by @Mervap in - [#18898](https://github.com/google-gemini/gemini-cli/pull/18898) -- fix(core): add error logging for IDE fetch failures by @yuvrajangadsingh in - [#17981](https://github.com/google-gemini/gemini-cli/pull/17981) -- feat(acp): support set_mode interface (#18890) by @Mervap in - [#18891](https://github.com/google-gemini/gemini-cli/pull/18891) -- fix(core): robust workspace-based IDE connection discovery by @ehedlund in - [#18443](https://github.com/google-gemini/gemini-cli/pull/18443) -- Deflake windows tests. by @jacob314 in - [#19511](https://github.com/google-gemini/gemini-cli/pull/19511) -- Fix: Avoid tool confirmation timeout when no UI listeners are present by - @pdHaku0 in [#17955](https://github.com/google-gemini/gemini-cli/pull/17955) -- format md file by @scidomino in - [#19474](https://github.com/google-gemini/gemini-cli/pull/19474) -- feat(cli): add experimental.useOSC52Copy setting by @scidomino in - [#19488](https://github.com/google-gemini/gemini-cli/pull/19488) -- feat(cli): replace loading phrases boolean with enum setting by @LyalinDotCom - in [#19347](https://github.com/google-gemini/gemini-cli/pull/19347) -- Update skill to adjust for generated results. by @g-samroberts in - [#19500](https://github.com/google-gemini/gemini-cli/pull/19500) -- Fix message too large issue. by @gundermanc in - [#19499](https://github.com/google-gemini/gemini-cli/pull/19499) -- fix(core): prevent duplicate tool approval entries in auto-saved.toml by - @Abhijit-2592 in - [#19487](https://github.com/google-gemini/gemini-cli/pull/19487) -- fix(core): resolve crash in ClearcutLogger when os.cpus() is empty by @Adib234 - in [#19555](https://github.com/google-gemini/gemini-cli/pull/19555) -- chore(core): improve encapsulation and remove unused exports by @adamfweidman - in [#19556](https://github.com/google-gemini/gemini-cli/pull/19556) -- Revert "Add generic searchable list to back settings and extensions (… by - @chrstnb in [#19434](https://github.com/google-gemini/gemini-cli/pull/19434) -- fix(core): improve error type extraction for telemetry by @yunaseoul in - [#19565](https://github.com/google-gemini/gemini-cli/pull/19565) -- fix: remove extra padding in Composer by @jackwotherspoon in - [#19529](https://github.com/google-gemini/gemini-cli/pull/19529) -- feat(plan): support configuring custom plans storage directory by @jerop in - [#19577](https://github.com/google-gemini/gemini-cli/pull/19577) -- Migrate files to resource or references folder. by @g-samroberts in - [#19503](https://github.com/google-gemini/gemini-cli/pull/19503) -- feat(policy): implement project-level policy support by @Abhijit-2592 in - [#18682](https://github.com/google-gemini/gemini-cli/pull/18682) -- feat(core): Implement parallel FC for read only tools. by @joshualitt in - [#18791](https://github.com/google-gemini/gemini-cli/pull/18791) -- chore(skills): adds pr-address-comments skill to work on PR feedback by - @mbleigh in [#19576](https://github.com/google-gemini/gemini-cli/pull/19576) -- refactor(sdk): introduce session-based architecture by @mbleigh in - [#19180](https://github.com/google-gemini/gemini-cli/pull/19180) -- fix(ci): add fallback JSON extraction to issue triage workflow by @bdmorgan in - [#19593](https://github.com/google-gemini/gemini-cli/pull/19593) -- feat(core): refine Edit and WriteFile tool schemas for Gemini 3 by - @SandyTao520 in - [#19476](https://github.com/google-gemini/gemini-cli/pull/19476) -- Changelog for v0.30.0-preview.3 by @gemini-cli-robot in - [#19585](https://github.com/google-gemini/gemini-cli/pull/19585) -- fix(plan): exclude EnterPlanMode tool from YOLO mode by @Adib234 in - [#19570](https://github.com/google-gemini/gemini-cli/pull/19570) -- chore: resolve build warnings and update dependencies by @mattKorwel in - [#18880](https://github.com/google-gemini/gemini-cli/pull/18880) -- feat(ui): add source indicators to slash commands by @ehedlund in - [#18839](https://github.com/google-gemini/gemini-cli/pull/18839) -- docs: refine Plan Mode documentation structure and workflow by @jerop in - [#19644](https://github.com/google-gemini/gemini-cli/pull/19644) -- Docs: Update release information regarding Gemini 3.1 by @jkcinouye in - [#19568](https://github.com/google-gemini/gemini-cli/pull/19568) -- fix(security): rate limit web_fetch tool to mitigate DDoS via prompt injection - by @mattKorwel in - [#19567](https://github.com/google-gemini/gemini-cli/pull/19567) -- Add initial implementation of /extensions explore command by @chrstnb in - [#19029](https://github.com/google-gemini/gemini-cli/pull/19029) -- fix: use discoverOAuthFromWWWAuthenticate for reactive OAuth flow (#18760) by - @maximus12793 in - [#19038](https://github.com/google-gemini/gemini-cli/pull/19038) -- Search updates by @alisa-alisa in - [#19482](https://github.com/google-gemini/gemini-cli/pull/19482) -- feat(cli): add support for numpad SS3 sequences by @scidomino in - [#19659](https://github.com/google-gemini/gemini-cli/pull/19659) -- feat(cli): enhance folder trust with configuration discovery and security - warnings by @galz10 in - [#19492](https://github.com/google-gemini/gemini-cli/pull/19492) -- feat(ui): improve startup warnings UX with dismissal and show-count limits by + [#21921](https://github.com/google-gemini/gemini-cli/pull/21921) +- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38 + in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835) +- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in + [#22065](https://github.com/google-gemini/gemini-cli/pull/22065) +- feat(core): support custom base URL via env vars by @junaiddshaukat in + [#21561](https://github.com/google-gemini/gemini-cli/pull/21561) +- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in + [#22051](https://github.com/google-gemini/gemini-cli/pull/22051) +- fix(core): silently retry API errors up to 3 times before halting session by @spencer426 in - [#19584](https://github.com/google-gemini/gemini-cli/pull/19584) -- feat(a2a): Add API key authentication provider by @adamfweidman in - [#19548](https://github.com/google-gemini/gemini-cli/pull/19548) -- Send accepted/removed lines with ACCEPT_FILE telemetry. by @gundermanc in - [#19670](https://github.com/google-gemini/gemini-cli/pull/19670) -- feat(models): support Gemini 3.1 Pro Preview and fixes by @sehoon38 in - [#19676](https://github.com/google-gemini/gemini-cli/pull/19676) -- feat(plan): enforce read-only constraints in Plan Mode by @mattKorwel in - [#19433](https://github.com/google-gemini/gemini-cli/pull/19433) -- fix(cli): allow perfect match @scripts/test-windows-paths.js completions to - submit on Enter by @spencer426 in - [#19562](https://github.com/google-gemini/gemini-cli/pull/19562) -- fix(core): treat 503 Service Unavailable as retryable quota error by @sehoon38 - in [#19642](https://github.com/google-gemini/gemini-cli/pull/19642) -- Update sidebar.json for to allow top nav tabs. by @g-samroberts in - [#19595](https://github.com/google-gemini/gemini-cli/pull/19595) -- security: strip deceptive Unicode characters from terminal output by @ehedlund - in [#19026](https://github.com/google-gemini/gemini-cli/pull/19026) -- Fixes 'input.on' is not a function error in Gemini CLI by @gundermanc in - [#19691](https://github.com/google-gemini/gemini-cli/pull/19691) -- Revert "feat(ui): add source indicators to slash commands" by @ehedlund in - [#19695](https://github.com/google-gemini/gemini-cli/pull/19695) -- security: implement deceptive URL detection and disclosure in tool - confirmations by @ehedlund in - [#19288](https://github.com/google-gemini/gemini-cli/pull/19288) -- fix(core): restore auth consent in headless mode and add unit tests by - @ehedlund in [#19689](https://github.com/google-gemini/gemini-cli/pull/19689) -- Fix unsafe assertions in code_assist folder. by @gundermanc in - [#19706](https://github.com/google-gemini/gemini-cli/pull/19706) -- feat(cli): make JetBrains warning more specific by @jacob314 in - [#19687](https://github.com/google-gemini/gemini-cli/pull/19687) -- fix(cli): extensions dialog UX polish by @jacob314 in - [#19685](https://github.com/google-gemini/gemini-cli/pull/19685) -- fix(cli): use getDisplayString for manual model selection in dialog by - @sehoon38 in [#19726](https://github.com/google-gemini/gemini-cli/pull/19726) -- feat(policy): repurpose "Always Allow" persistence to workspace level by - @Abhijit-2592 in - [#19707](https://github.com/google-gemini/gemini-cli/pull/19707) -- fix(cli): re-enable CLI banner by @sehoon38 in - [#19741](https://github.com/google-gemini/gemini-cli/pull/19741) -- Disallow and suppress unsafe assignment by @gundermanc in - [#19736](https://github.com/google-gemini/gemini-cli/pull/19736) -- feat(core): migrate read_file to 1-based start_line/end_line parameters by - @adamfweidman in - [#19526](https://github.com/google-gemini/gemini-cli/pull/19526) -- feat(cli): improve CTRL+O experience for both standard and alternate screen - buffer (ASB) modes by @jwhelangoog in - [#19010](https://github.com/google-gemini/gemini-cli/pull/19010) -- Utilize pipelining of grep_search -> read_file to eliminate turns by - @gundermanc in - [#19574](https://github.com/google-gemini/gemini-cli/pull/19574) -- refactor(core): remove unsafe type assertions in error utils (Phase 1.1) by - @mattKorwel in - [#19750](https://github.com/google-gemini/gemini-cli/pull/19750) -- Disallow unsafe returns. by @gundermanc in - [#19767](https://github.com/google-gemini/gemini-cli/pull/19767) -- fix(cli): filter subagent sessions from resume history by @abhipatel12 in - [#19698](https://github.com/google-gemini/gemini-cli/pull/19698) -- chore(lint): fix lint errors seen when running npm run lint by @abhipatel12 in - [#19844](https://github.com/google-gemini/gemini-cli/pull/19844) -- feat(core): remove unnecessary login verbiage from Code Assist auth by + [#21989](https://github.com/google-gemini/gemini-cli/pull/21989) +- feat(core): simplify subagent success UI and improve early termination display + by @abhipatel12 in + [#21917](https://github.com/google-gemini/gemini-cli/pull/21917) +- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in + [#22056](https://github.com/google-gemini/gemini-cli/pull/22056) +- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7 + in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383) +- feat(core): implement SandboxManager interface and config schema by @galz10 in + [#21774](https://github.com/google-gemini/gemini-cli/pull/21774) +- docs: document npm deprecation warnings as safe to ignore by @h30s in + [#20692](https://github.com/google-gemini/gemini-cli/pull/20692) +- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in + [#22044](https://github.com/google-gemini/gemini-cli/pull/22044) +- fix(core): propagate subagent context to policy engine by @NTaylorMullen in + [#22086](https://github.com/google-gemini/gemini-cli/pull/22086) +- fix(cli): resolve skill uninstall failure when skill name is updated by @NTaylorMullen in - [#19861](https://github.com/google-gemini/gemini-cli/pull/19861) -- fix(plan): time share by approval mode dashboard reporting negative time - shares by @Adib234 in - [#19847](https://github.com/google-gemini/gemini-cli/pull/19847) -- fix(core): allow any preview model in quota access check by @bdmorgan in - [#19867](https://github.com/google-gemini/gemini-cli/pull/19867) -- fix(core): prevent omission placeholder deletions in replace/write_file by - @nsalerni in [#19870](https://github.com/google-gemini/gemini-cli/pull/19870) -- fix(core): add uniqueness guard to edit tool by @Shivangisharma4 in - [#19890](https://github.com/google-gemini/gemini-cli/pull/19890) -- refactor(config): remove enablePromptCompletion from settings by @sehoon38 in - [#19974](https://github.com/google-gemini/gemini-cli/pull/19974) -- refactor(core): move session conversion logic to core by @abhipatel12 in - [#19972](https://github.com/google-gemini/gemini-cli/pull/19972) -- Fix: Persist manual model selection on restart #19864 by @Nixxx19 in - [#19891](https://github.com/google-gemini/gemini-cli/pull/19891) -- fix(core): increase default retry attempts and add quota error backoff by - @sehoon38 in [#19949](https://github.com/google-gemini/gemini-cli/pull/19949) -- feat(core): add policy chain support for Gemini 3.1 by @sehoon38 in - [#19991](https://github.com/google-gemini/gemini-cli/pull/19991) -- Updates command reference and /stats command. by @g-samroberts in - [#19794](https://github.com/google-gemini/gemini-cli/pull/19794) -- Fix for silent failures in non-interactive mode by @owenofbrien in - [#19905](https://github.com/google-gemini/gemini-cli/pull/19905) -- fix(plan): allow plan mode writes on Windows and fix prompt paths by @Adib234 - in [#19658](https://github.com/google-gemini/gemini-cli/pull/19658) -- fix(core): prevent OAuth server crash on unexpected requests by @reyyanxahmed - in [#19668](https://github.com/google-gemini/gemini-cli/pull/19668) -- feat: Map tool kinds to explicit ACP.ToolKind values and update test … by - @sripasg in [#19547](https://github.com/google-gemini/gemini-cli/pull/19547) -- chore: restrict gemini-automted-issue-triage to only allow echo by @galz10 in - [#20047](https://github.com/google-gemini/gemini-cli/pull/20047) -- Allow ask headers longer than 16 chars by @scidomino in - [#20041](https://github.com/google-gemini/gemini-cli/pull/20041) -- fix(core): prevent state corruption in McpClientManager during collis by @h30s - in [#19782](https://github.com/google-gemini/gemini-cli/pull/19782) -- fix(bundling): copy devtools package to bundle for runtime resolution by - @SandyTao520 in - [#19766](https://github.com/google-gemini/gemini-cli/pull/19766) -- feat(policy): Support MCP Server Wildcards in Policy Engine by @jerop in - [#20024](https://github.com/google-gemini/gemini-cli/pull/20024) -- docs(CONTRIBUTING): update React DevTools version to 6 by @mmgok in - [#20014](https://github.com/google-gemini/gemini-cli/pull/20014) -- feat(core): optimize tool descriptions and schemas for Gemini 3 by - @aishaneeshah in - [#19643](https://github.com/google-gemini/gemini-cli/pull/19643) -- feat(core): implement experimental direct web fetch by @mbleigh in - [#19557](https://github.com/google-gemini/gemini-cli/pull/19557) -- feat(core): replace expected_replacements with allow_multiple in replace tool - by @SandyTao520 in - [#20033](https://github.com/google-gemini/gemini-cli/pull/20033) -- fix(sandbox): harden image packaging integrity checks by @aviralgarg05 in - [#19552](https://github.com/google-gemini/gemini-cli/pull/19552) -- fix(core): allow environment variable expansion and explicit overrides for MCP - servers by @galz10 in - [#18837](https://github.com/google-gemini/gemini-cli/pull/18837) -- feat(policy): Implement Tool Annotation Matching in Policy Engine by @jerop in - [#20029](https://github.com/google-gemini/gemini-cli/pull/20029) -- fix(core): prevent utility calls from changing session active model by + [#22085](https://github.com/google-gemini/gemini-cli/pull/22085) +- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in + [#22076](https://github.com/google-gemini/gemini-cli/pull/22076) +- fix(policy): ensure user policies are loaded when policyPaths is empty by + @NTaylorMullen in + [#22090](https://github.com/google-gemini/gemini-cli/pull/22090) +- Docs: Add documentation for model steering (experimental). by @jkcinouye in + [#21154](https://github.com/google-gemini/gemini-cli/pull/21154) +- Add issue for automated changelogs by @g-samroberts in + [#21912](https://github.com/google-gemini/gemini-cli/pull/21912) +- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by + @spencer426 in + [#22104](https://github.com/google-gemini/gemini-cli/pull/22104) +- feat(core): differentiate User-Agent for a2a-server and ACP clients by + @bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059) +- refactor(core): extract ExecutionLifecycleService for tool backgrounding by @adamfweidman in - [#20035](https://github.com/google-gemini/gemini-cli/pull/20035) -- fix(cli): skip workspace policy loading when in home directory by + [#21717](https://github.com/google-gemini/gemini-cli/pull/21717) +- feat: Display pending and confirming tool calls by @sripasg in + [#22106](https://github.com/google-gemini/gemini-cli/pull/22106) +- feat(browser): implement input blocker overlay during automation by + @kunal-10-cloud in + [#21132](https://github.com/google-gemini/gemini-cli/pull/21132) +- fix: register themes on extension load not start by @jackwotherspoon in + [#22148](https://github.com/google-gemini/gemini-cli/pull/22148) +- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in + [#22156](https://github.com/google-gemini/gemini-cli/pull/22156) +- chore: remove unnecessary log for themes by @jackwotherspoon in + [#22165](https://github.com/google-gemini/gemini-cli/pull/22165) +- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in + subagents by @abhipatel12 in + [#22069](https://github.com/google-gemini/gemini-cli/pull/22069) +- fix(cli): validate --model argument at startup by @JaisalJain in + [#21393](https://github.com/google-gemini/gemini-cli/pull/21393) +- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in + [#21802](https://github.com/google-gemini/gemini-cli/pull/21802) +- feat(telemetry): add Clearcut instrumentation for AI credits billing events by + @gsquared94 in + [#22153](https://github.com/google-gemini/gemini-cli/pull/22153) +- feat(core): add google credentials provider for remote agents by @adamfweidman + in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024) +- test(cli): add integration test for node deprecation warnings by @Nixxx19 in + [#20215](https://github.com/google-gemini/gemini-cli/pull/20215) +- feat(cli): allow safe tools to execute concurrently while agent is busy by + @spencer426 in + [#21988](https://github.com/google-gemini/gemini-cli/pull/21988) +- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in + [#21933](https://github.com/google-gemini/gemini-cli/pull/21933) +- update vulnerable deps by @scidomino in + [#22180](https://github.com/google-gemini/gemini-cli/pull/22180) +- fix(core): fix startup stats to use int values for timestamps and durations by + @yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201) +- fix(core): prevent duplicate tool schemas for instantiated tools by + @abhipatel12 in + [#22204](https://github.com/google-gemini/gemini-cli/pull/22204) +- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman + in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199) +- fix(core/ide): add Antigravity CLI fallbacks by @apfine in + [#22030](https://github.com/google-gemini/gemini-cli/pull/22030) +- fix(browser): fix duplicate function declaration error in browser agent by + @gsquared94 in + [#22207](https://github.com/google-gemini/gemini-cli/pull/22207) +- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah + in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313) +- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in + [#22194](https://github.com/google-gemini/gemini-cli/pull/22194) +- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in + [#22117](https://github.com/google-gemini/gemini-cli/pull/22117) +- fix: remove unused img.png from project root by @SandyTao520 in + [#22222](https://github.com/google-gemini/gemini-cli/pull/22222) +- docs(local model routing): add docs on how to use Gemma for local model + routing by @douglas-reid in + [#21365](https://github.com/google-gemini/gemini-cli/pull/21365) +- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in + [#21403](https://github.com/google-gemini/gemini-cli/pull/21403) +- fix(cli): escape @ symbols on paste to prevent unintended file expansion by + @krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239) +- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in + [#22214](https://github.com/google-gemini/gemini-cli/pull/22214) +- docs: clarify that tools.core is an allowlist for ALL built-in tools by + @hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813) +- docs(plan): document hooks with plan mode by @ruomengz in + [#22197](https://github.com/google-gemini/gemini-cli/pull/22197) +- Changelog for v0.33.1 by @gemini-cli-robot in + [#22235](https://github.com/google-gemini/gemini-cli/pull/22235) +- build(ci): fix false positive evals trigger on merge commits by @gundermanc in + [#22237](https://github.com/google-gemini/gemini-cli/pull/22237) +- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by + @abhipatel12 in + [#22255](https://github.com/google-gemini/gemini-cli/pull/22255) +- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in + [#22115](https://github.com/google-gemini/gemini-cli/pull/22115) +- feat(core): increase sub-agent turn and time limits by @bdmorgan in + [#22196](https://github.com/google-gemini/gemini-cli/pull/22196) +- feat(core): instrument file system tools for JIT context discovery by + @SandyTao520 in + [#22082](https://github.com/google-gemini/gemini-cli/pull/22082) +- refactor(ui): extract pure session browser utilities by @abhipatel12 in + [#22256](https://github.com/google-gemini/gemini-cli/pull/22256) +- fix(plan): Fix AskUser evals by @Adib234 in + [#22074](https://github.com/google-gemini/gemini-cli/pull/22074) +- fix(settings): prevent j/k navigation keys from intercepting edit buffer input + by @student-ankitpandit in + [#21865](https://github.com/google-gemini/gemini-cli/pull/21865) +- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in + [#21790](https://github.com/google-gemini/gemini-cli/pull/21790) +- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in + [#22190](https://github.com/google-gemini/gemini-cli/pull/22190) +- fix(core): show descriptive error messages when saving settings fails by + @afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095) +- docs(core): add authentication guide for remote subagents by @adamfweidman in + [#22178](https://github.com/google-gemini/gemini-cli/pull/22178) +- docs: overhaul subagents documentation and add /agents command by @abhipatel12 + in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345) +- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in + [#22348](https://github.com/google-gemini/gemini-cli/pull/22348) +- test: add Object.create context regression test and tool confirmation + integration test by @gsquared94 in + [#22356](https://github.com/google-gemini/gemini-cli/pull/22356) +- feat(tracker): return TodoList display for tracker tools by @anj-s in + [#22060](https://github.com/google-gemini/gemini-cli/pull/22060) +- feat(agent): add allowed domain restrictions for browser agent by + @cynthialong0-0 in + [#21775](https://github.com/google-gemini/gemini-cli/pull/21775) +- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by + @gemini-cli-robot in + [#22251](https://github.com/google-gemini/gemini-cli/pull/22251) +- Move keychain fallback to keychain service by @chrstnb in + [#22332](https://github.com/google-gemini/gemini-cli/pull/22332) +- feat(core): integrate SandboxManager to sandbox all process-spawning tools by + @galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) +- fix(cli): support CJK input and full Unicode scalar values in terminal + protocols by @scidomino in + [#22353](https://github.com/google-gemini/gemini-cli/pull/22353) +- Promote stable tests. by @gundermanc in + [#22253](https://github.com/google-gemini/gemini-cli/pull/22253) +- feat(tracker): add tracker policy by @anj-s in + [#22379](https://github.com/google-gemini/gemini-cli/pull/22379) +- feat(security): add disableAlwaysAllow setting to disable auto-approvals by + @galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941) +- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in + [#22378](https://github.com/google-gemini/gemini-cli/pull/22378) +- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10 + in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231) +- fix(core): use session-specific temp directory for task tracker by @anj-s in + [#22382](https://github.com/google-gemini/gemini-cli/pull/22382) +- Fix issue where config was undefined. by @gundermanc in + [#22397](https://github.com/google-gemini/gemini-cli/pull/22397) +- fix(core): deduplicate project memory when JIT context is enabled by + @SandyTao520 in + [#22234](https://github.com/google-gemini/gemini-cli/pull/22234) +- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by @Abhijit-2592 in - [#20054](https://github.com/google-gemini/gemini-cli/pull/20054) -- fix(scripts): Add Windows (win32/x64) support to lint.js by @ZafeerMahmood in - [#16193](https://github.com/google-gemini/gemini-cli/pull/16193) -- fix(a2a-server): Remove unsafe type assertions in agent by @Nixxx19 in - [#19723](https://github.com/google-gemini/gemini-cli/pull/19723) -- Fix: Handle corrupted token file gracefully when switching auth types (#19845) - by @Nixxx19 in - [#19850](https://github.com/google-gemini/gemini-cli/pull/19850) -- fix critical dep vulnerability by @scidomino in - [#20087](https://github.com/google-gemini/gemini-cli/pull/20087) -- Add new setting to configure maxRetries by @kevinjwang1 in - [#20064](https://github.com/google-gemini/gemini-cli/pull/20064) -- Stabilize tests. by @gundermanc in - [#20095](https://github.com/google-gemini/gemini-cli/pull/20095) -- make windows tests mandatory by @scidomino in - [#20096](https://github.com/google-gemini/gemini-cli/pull/20096) -- Add 3.1 pro preview to behavioral evals. by @gundermanc in - [#20088](https://github.com/google-gemini/gemini-cli/pull/20088) -- feat:PR-rate-limit by @JagjeevanAK in - [#19804](https://github.com/google-gemini/gemini-cli/pull/19804) -- feat(cli): allow expanding full details of MCP tool on approval by @y-okt in - [#19916](https://github.com/google-gemini/gemini-cli/pull/19916) -- feat(security): Introduce Conseca framework by @shrishabh in - [#13193](https://github.com/google-gemini/gemini-cli/pull/13193) -- fix(cli): Remove unsafe type assertions in activityLogger #19713 by @Nixxx19 - in [#19745](https://github.com/google-gemini/gemini-cli/pull/19745) -- feat: implement AfterTool tail tool calls by @googlestrobe in - [#18486](https://github.com/google-gemini/gemini-cli/pull/18486) -- ci(actions): fix PR rate limiter excluding maintainers by @scidomino in - [#20117](https://github.com/google-gemini/gemini-cli/pull/20117) -- Shortcuts: Move SectionHeader title below top line and refine styling by - @keithguerin in - [#18721](https://github.com/google-gemini/gemini-cli/pull/18721) -- refactor(ui): Update and simplify use of gray colors in themes by @keithguerin - in [#20141](https://github.com/google-gemini/gemini-cli/pull/20141) -- fix punycode2 by @jacob314 in - [#20154](https://github.com/google-gemini/gemini-cli/pull/20154) -- feat(ide): add GEMINI_CLI_IDE_PID env var to override IDE process detection by - @kiryltech in [#15842](https://github.com/google-gemini/gemini-cli/pull/15842) -- feat(policy): Propagate Tool Annotations for MCP Servers by @jerop in - [#20083](https://github.com/google-gemini/gemini-cli/pull/20083) -- fix(a2a-server): pass allowedTools settings to core Config by @reyyanxahmed in - [#19680](https://github.com/google-gemini/gemini-cli/pull/19680) -- feat(mcp): add progress bar, throttling, and input validation for MCP tool - progress by @jasmeetsb in - [#19772](https://github.com/google-gemini/gemini-cli/pull/19772) -- feat(policy): centralize plan mode tool visibility in policy engine by @jerop - in [#20178](https://github.com/google-gemini/gemini-cli/pull/20178) -- feat(browser): implement experimental browser agent by @gsquared94 in - [#19284](https://github.com/google-gemini/gemini-cli/pull/19284) -- feat(plan): summarize work after executing a plan by @jerop in - [#19432](https://github.com/google-gemini/gemini-cli/pull/19432) -- fix(core): create new McpClient on restart to apply updated config by @h30s in - [#20126](https://github.com/google-gemini/gemini-cli/pull/20126) -- Changelog for v0.30.0-preview.5 by @gemini-cli-robot in - [#20107](https://github.com/google-gemini/gemini-cli/pull/20107) -- Update packages. by @jacob314 in - [#20152](https://github.com/google-gemini/gemini-cli/pull/20152) -- Fix extension env dir loading issue by @chrstnb in - [#20198](https://github.com/google-gemini/gemini-cli/pull/20198) -- restrict /assign to help-wanted issues by @scidomino in - [#20207](https://github.com/google-gemini/gemini-cli/pull/20207) -- feat(plan): inject message when user manually exits Plan mode by @jerop in - [#20203](https://github.com/google-gemini/gemini-cli/pull/20203) -- feat(extensions): enforce folder trust for local extension install by @galz10 - in [#19703](https://github.com/google-gemini/gemini-cli/pull/19703) -- feat(hooks): adds support for RuntimeHook functions. by @mbleigh in - [#19598](https://github.com/google-gemini/gemini-cli/pull/19598) -- Docs: Update UI links. by @jkcinouye in - [#20224](https://github.com/google-gemini/gemini-cli/pull/20224) -- feat: prompt users to run /terminal-setup with yes/no by @ishaanxgupta in - [#16235](https://github.com/google-gemini/gemini-cli/pull/16235) -- fix: additional high vulnerabilities (minimatch, cross-spawn) by @adamfweidman - in [#20221](https://github.com/google-gemini/gemini-cli/pull/20221) -- feat(telemetry): Add context breakdown to API response event by @SandyTao520 - in [#19699](https://github.com/google-gemini/gemini-cli/pull/19699) -- Docs: Add nested sub-folders for related topics by @g-samroberts in - [#20235](https://github.com/google-gemini/gemini-cli/pull/20235) -- feat(plan): support automatic model switching for Plan Mode by @jerop in - [#20240](https://github.com/google-gemini/gemini-cli/pull/20240) + [#21503](https://github.com/google-gemini/gemini-cli/pull/21503) +- fix(core): fix manual deletion of subagent histories by @abhipatel12 in + [#22407](https://github.com/google-gemini/gemini-cli/pull/22407) +- Add registry var by @kevinjwang1 in + [#22224](https://github.com/google-gemini/gemini-cli/pull/22224) +- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in + [#22302](https://github.com/google-gemini/gemini-cli/pull/22302) +- fix(cli): improve command conflict handling for skills by @NTaylorMullen in + [#21942](https://github.com/google-gemini/gemini-cli/pull/21942) +- fix(core): merge user settings with extension-provided MCP servers by + @abhipatel12 in + [#22484](https://github.com/google-gemini/gemini-cli/pull/22484) +- fix(core): skip discovery for incomplete MCP configs and resolve merge race + condition by @abhipatel12 in + [#22494](https://github.com/google-gemini/gemini-cli/pull/22494) +- fix(automation): harden stale PR closer permissions and maintainer detection + by @bdmorgan in + [#22558](https://github.com/google-gemini/gemini-cli/pull/22558) +- fix(automation): evaluate staleness before checking protected labels by + @bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561) +- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with + pre-built bundle by @cynthialong0-0 in + [#22213](https://github.com/google-gemini/gemini-cli/pull/22213) +- perf: optimize TrackerService dependency checks by @anj-s in + [#22384](https://github.com/google-gemini/gemini-cli/pull/22384) +- docs(policy): remove trailing space from commandPrefix examples by @kawasin73 + in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264) +- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in + [#22661](https://github.com/google-gemini/gemini-cli/pull/22661) +- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled + tool calls. by @sripasg in + [#22230](https://github.com/google-gemini/gemini-cli/pull/22230) +- Disallow Object.create() and reflect. by @gundermanc in + [#22408](https://github.com/google-gemini/gemini-cli/pull/22408) +- Guard pro model usage by @sehoon38 in + [#22665](https://github.com/google-gemini/gemini-cli/pull/22665) +- refactor(core): Creates AgentSession abstraction for consolidated agent + interface. by @mbleigh in + [#22270](https://github.com/google-gemini/gemini-cli/pull/22270) +- docs(changelog): remove internal commands from release notes by + @jackwotherspoon in + [#22529](https://github.com/google-gemini/gemini-cli/pull/22529) +- feat: enable subagents by @abhipatel12 in + [#22386](https://github.com/google-gemini/gemini-cli/pull/22386) +- feat(extensions): implement cryptographic integrity verification for extension + updates by @ehedlund in + [#21772](https://github.com/google-gemini/gemini-cli/pull/21772) +- feat(tracker): polish UI sorting and formatting by @anj-s in + [#22437](https://github.com/google-gemini/gemini-cli/pull/22437) +- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in + [#22220](https://github.com/google-gemini/gemini-cli/pull/22220) +- fix(core): fix three JIT context bugs in read_file, read_many_files, and + memoryDiscovery by @SandyTao520 in + [#22679](https://github.com/google-gemini/gemini-cli/pull/22679) +- refactor(core): introduce InjectionService with source-aware injection and + backend-native background completions by @adamfweidman in + [#22544](https://github.com/google-gemini/gemini-cli/pull/22544) +- Linux sandbox bubblewrap by @DavidAPierce in + [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) +- feat(core): increase thought signature retry resilience by @bdmorgan in + [#22202](https://github.com/google-gemini/gemini-cli/pull/22202) +- feat(core): implement Stage 2 security and consistency improvements for + web_fetch by @aishaneeshah in + [#22217](https://github.com/google-gemini/gemini-cli/pull/22217) +- refactor(core): replace positional execute params with ExecuteOptions bag by + @adamfweidman in + [#22674](https://github.com/google-gemini/gemini-cli/pull/22674) +- feat(config): enable JIT context loading by default by @SandyTao520 in + [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) +- fix(config): ensure discoveryMaxDirs is passed to global config during + initialization by @kevin-ramdass in + [#22744](https://github.com/google-gemini/gemini-cli/pull/22744) +- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in + [#22668](https://github.com/google-gemini/gemini-cli/pull/22668) +- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in + [#22393](https://github.com/google-gemini/gemini-cli/pull/22393) +- feat(core): add foundation for subagent tool isolation by @akh64bit in + [#22708](https://github.com/google-gemini/gemini-cli/pull/22708) +- fix(core): handle surrogate pairs in truncateString by @sehoon38 in + [#22754](https://github.com/google-gemini/gemini-cli/pull/22754) +- fix(cli): override j/k navigation in settings dialog to fix search input + conflict by @sehoon38 in + [#22800](https://github.com/google-gemini/gemini-cli/pull/22800) +- feat(plan): add 'All the above' option to multi-select AskUser questions by + @Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365) +- docs: distribute package-specific GEMINI.md context to each package by + @SandyTao520 in + [#22734](https://github.com/google-gemini/gemini-cli/pull/22734) +- fix(cli): clean up stale pasted placeholder metadata after word/line deletions + by @Jomak-x in + [#20375](https://github.com/google-gemini/gemini-cli/pull/20375) +- refactor(core): align JIT memory placement with tiered context model by + @SandyTao520 in + [#22766](https://github.com/google-gemini/gemini-cli/pull/22766) +- Linux sandbox seccomp by @DavidAPierce in + [#22815](https://github.com/google-gemini/gemini-cli/pull/22815) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.0 +https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.2 diff --git a/docs/cli/checkpointing.md b/docs/cli/checkpointing.md index 0be8bd9508..3a4a690cea 100644 --- a/docs/cli/checkpointing.md +++ b/docs/cli/checkpointing.md @@ -39,7 +39,9 @@ file in your project's temporary directory, typically located at The Checkpointing feature is disabled by default. To enable it, you need to edit your `settings.json` file. -> **Note:** The `--checkpointing` command-line flag was removed in version + +> [!CAUTION] +> The `--checkpointing` command-line flag was removed in version > 0.11.0. Checkpointing can now only be enabled through the `settings.json` > configuration file. diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index c4eecbd520..bd13808323 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -8,8 +8,9 @@ and parameters. | Command | Description | Example | | ---------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- | | `gemini` | Start interactive REPL | `gemini` | -| `gemini "query"` | Query non-interactively, then exit | `gemini "explain this project"` | -| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini` | +| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` | +| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` | +| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`
`Get-Content logs.txt \| gemini` | | `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` | | `gemini -r "latest"` | Continue the most recent session across all folders | `gemini -r "latest"` | | `gemini -r "latest" "query"` | Continue a previous session with a new prompt | `gemini -r "latest" "Check for type errors"` | @@ -20,9 +21,24 @@ and parameters. ### Positional arguments -| Argument | Type | Description | -| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ | -| `query` | string (variadic) | Positional prompt. Defaults to one-shot mode. Use `-i/--prompt-interactive` to execute and continue interactively. | +| Argument | Type | Description | +| -------- | ----------------- | ---------------------------------------------------------------------------------------------------------- | +| `query` | string (variadic) | Positional prompt. Defaults to interactive mode in a TTY. Use `-p/--prompt` for non-interactive execution. | + +## Interactive commands + +These commands are available within the interactive REPL. + +| Command | Description | +| -------------------- | ---------------------------------------- | +| `/skills reload` | Reload discovered skills from disk | +| `/agents reload` | Reload the agent registry | +| `/commands reload` | Reload custom slash commands | +| `/memory reload` | Reload context files (e.g., `GEMINI.md`) | +| `/mcp reload` | Restart and reload MCP servers | +| `/extensions reload` | Reload all active extensions | +| `/help` | Show help for all commands | +| `/quit` | Exit the interactive session | ## CLI Options @@ -32,8 +48,9 @@ and parameters. | `--version` | `-v` | - | - | Show CLI version number and exit | | `--help` | `-h` | - | - | Show help information | | `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | -| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | +| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | | `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index e84839b8a3..6fcce4e825 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -30,7 +30,9 @@ separator (`/` or `\`) being converted to a colon (`:`). - A file at `/.gemini/commands/git/commit.toml` becomes the namespaced command `/git:commit`. -> [!TIP] After creating or modifying `.toml` command files, run + +> [!TIP] +> After creating or modifying `.toml` command files, run > `/commands reload` to pick up your changes without restarting the CLI. ## TOML file format (v1) @@ -177,10 +179,10 @@ ensure that only intended commands can be run. automatically shell-escaped (see [Context-Aware Injection](#1-context-aware-injection-with-args) above). 3. **Robust parsing:** The parser correctly handles complex shell commands that - include nested braces, such as JSON payloads. **Note:** The content inside - `!{...}` must have balanced braces (`{` and `}`). If you need to execute a - command containing unbalanced braces, consider wrapping it in an external - script file and calling the script within the `!{...}` block. + include nested braces, such as JSON payloads. The content inside `!{...}` + must have balanced braces (`{` and `}`). If you need to execute a command + containing unbalanced braces, consider wrapping it in an external script + file and calling the script within the `!{...}` block. 4. **Security check and confirmation:** The CLI performs a security check on the final, resolved command (after arguments are escaped and substituted). A dialog will appear showing the exact command(s) to be executed. @@ -278,11 +280,20 @@ Let's create a global command that asks the model to refactor a piece of code. First, ensure the user commands directory exists, then create a `refactor` subdirectory for organization and the final TOML file. +**macOS/Linux** + ```bash mkdir -p ~/.gemini/commands/refactor touch ~/.gemini/commands/refactor/pure.toml ``` +**Windows (PowerShell)** + +```powershell +New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\commands\refactor" +New-Item -ItemType File -Force -Path "$env:USERPROFILE\.gemini\commands\refactor\pure.toml" +``` + **2. Add the content to the file:** Open `~/.gemini/commands/refactor/pure.toml` in your editor and add the diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index b6d469755b..5e9cede33a 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -5,9 +5,11 @@ and managing Gemini CLI in an enterprise environment. By leveraging system-level settings, administrators can enforce security policies, manage tool access, and ensure a consistent experience for all users. -> **A note on security:** The patterns described in this document are intended -> to help administrators create a more controlled and secure environment for -> using Gemini CLI. However, they should not be considered a foolproof security + +> [!WARNING] +> The patterns described in this document are intended to help +> administrators create a more controlled and secure environment for using +> Gemini CLI. However, they should not be considered a foolproof security > boundary. A determined user with sufficient privileges on their local machine > may still be able to circumvent these configurations. These measures are > designed to prevent accidental misuse and enforce corporate policy in a @@ -203,6 +205,15 @@ with the actual Gemini CLI process, which inherits the environment variable. This makes it significantly more difficult for a user to bypass the enforced settings. +**PowerShell Profile (Windows alternative):** + +On Windows, administrators can achieve similar results by adding the environment +variable to the system-wide or user-specific PowerShell profile: + +```powershell +Add-Content -Path $PROFILE -Value '$env:GEMINI_CLI_SYSTEM_SETTINGS_PATH="C:\ProgramData\gemini-cli\settings.json"' +``` + ## User isolation in shared environments In shared compute environments (like ML experiment runners or shared build @@ -214,18 +225,28 @@ use the `GEMINI_CLI_HOME` environment variable to point to a unique directory for a specific user or job. The CLI will create a `.gemini` folder inside the specified path. +**macOS/Linux** + ```bash # Isolate state for a specific job export GEMINI_CLI_HOME="/tmp/gemini-job-123" gemini ``` +**Windows (PowerShell)** + +```powershell +# Isolate state for a specific job +$env:GEMINI_CLI_HOME="C:\temp\gemini-job-123" +gemini +``` + ## Restricting tool access You can significantly enhance security by controlling which tools the Gemini model can use. This is achieved through the `tools.core` setting and the [Policy Engine](../reference/policy-engine.md). For a list of available tools, -see the [Tools documentation](../tools/index.md). +see the [Tools reference](../reference/tools.md). ### Allowlisting with `coreTools` @@ -261,10 +282,12 @@ environment to a blocklist. } ``` -**Security note:** Blocklisting with `excludeTools` is less secure than -allowlisting with `coreTools`, as it relies on blocking known-bad commands, and -clever users may find ways to bypass simple string-based blocks. **Allowlisting -is the recommended approach.** + +> [!WARNING] +> Blocklisting with `excludeTools` is less secure than +> allowlisting with `coreTools`, as it relies on blocking known-bad commands, +> and clever users may find ways to bypass simple string-based blocks. +> **Allowlisting is the recommended approach.** ### Disabling YOLO mode @@ -289,8 +312,8 @@ unintended tool execution. ## Managing custom tools (MCP servers) If your organization uses custom tools via -[Model-Context Protocol (MCP) servers](../reference/tools-api.md), it is crucial -to understand how server configurations are managed to apply security policies +[Model-Context Protocol (MCP) servers](../tools/mcp-server.md), it is crucial to +understand how server configurations are managed to apply security policies effectively. ### How MCP server configurations are merged @@ -475,8 +498,10 @@ other events. For more information, see the } ``` -**Note:** Ensure that `logPrompts` is set to `false` in an enterprise setting to -avoid collecting potentially sensitive information from user prompts. + +> [!NOTE] +> Ensure that `logPrompts` is set to `false` in an enterprise setting to +> avoid collecting potentially sensitive information from user prompts. ## Authentication diff --git a/docs/cli/gemini-md.md b/docs/cli/gemini-md.md index 95f46ae095..624b2fc566 100644 --- a/docs/cli/gemini-md.md +++ b/docs/cli/gemini-md.md @@ -63,7 +63,7 @@ You can interact with the loaded context files by using the `/memory` command. - **`/memory show`**: Displays the full, concatenated content of the current hierarchical memory. This lets you inspect the exact instructional context being provided to the model. -- **`/memory refresh`**: Forces a re-scan and reload of all `GEMINI.md` files +- **`/memory reload`**: Forces a re-scan and reload of all `GEMINI.md` files from all configured locations. - **`/memory add `**: Appends your text to your global `~/.gemini/GEMINI.md` file. This lets you add persistent memories on the fly. diff --git a/docs/cli/git-worktrees.md b/docs/cli/git-worktrees.md new file mode 100644 index 0000000000..5020b3fa9a --- /dev/null +++ b/docs/cli/git-worktrees.md @@ -0,0 +1,107 @@ +# Git Worktrees (experimental) + +When working on multiple tasks at once, you can use Git worktrees to give each +Gemini session its own copy of the codebase. Git worktrees create separate +working directories that each have their own files and branch while sharing the +same repository history. This prevents changes in one session from colliding +with another. + +Learn more about [session management](./session-management.md). + + +> [!NOTE] +> This is an experimental feature currently under active development. Your +> feedback is invaluable as we refine this feature. If you have ideas, +> suggestions, or encounter issues: +> +> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub. +> - Use the **/bug** command within Gemini CLI to file an issue. + +Learn more in the official Git worktree +[documentation](https://git-scm.com/docs/git-worktree). + +## How to enable Git worktrees + +Git worktrees are an experimental feature. You must enable them in your settings +using the `/settings` command or by manually editing your `settings.json` file. + +1. Use the `/settings` command. +2. Search for and set **Enable Git Worktrees** to `true`. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "experimental": { + "worktrees": true + } +} +``` + +## How to use Git worktrees + +Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini +CLI in it. + +- **Start with a specific name:** The value you pass becomes both the directory + name (within `.gemini/worktrees/`) and the branch name. + + ```bash + gemini --worktree feature-search + ``` + +- **Start with a random name:** If you omit the name, Gemini generates a random + one automatically (for example, `worktree-a1b2c3d4`). + + ```bash + gemini --worktree + ``` + + +> [!NOTE] +> Remember to initialize your development environment in each new +> worktree according to your project's setup. Depending on your stack, this +> might include running dependency installation (`npm install`, `yarn`), setting +> up virtual environments, or following your project's standard build process. + +## How to exit a Git worktree session + +When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the +worktree intact so your work is not lost. This includes your uncommitted changes +(modified files, staged changes, or untracked files) and any new commits you +have made. + +Gemini prioritizes a fast and safe exit: it **does not automatically delete** +your worktree or branch. You are responsible for cleaning up your worktrees +manually once you are finished with them. + +When you exit, Gemini displays instructions on how to resume your work or how to +manually remove the worktree if you no longer need it. + +## Resuming work in a Git worktree + +To resume a session in a worktree, navigate to the worktree directory and start +Gemini CLI with the `--resume` flag and the session ID: + +```bash +cd .gemini/worktrees/feature-search +gemini --resume +``` + +## Managing Git worktrees manually + +For more control over worktree location and branch configuration, or to clean up +a preserved worktree, you can use Git directly: + +- **Clean up a preserved Git worktree:** + ```bash + git worktree remove .gemini/worktrees/feature-search --force + git branch -D worktree-feature-search + ``` +- **Create a Git worktree manually:** + ```bash + git worktree add ../project-feature-search -b feature-search + cd ../project-feature-search && gemini + ``` + +[Open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/headless.md b/docs/cli/headless.md index 7de3287639..c83ce70d0e 100644 --- a/docs/cli/headless.md +++ b/docs/cli/headless.md @@ -6,7 +6,7 @@ structured text or JSON output without an interactive terminal UI. ## Technical reference Headless mode is triggered when the CLI is run in a non-TTY environment or when -providing a query as a positional argument without the interactive flag. +providing a query with the `-p` (or `--prompt`) flag. ### Output formats @@ -31,7 +31,8 @@ Returns a stream of newline-delimited JSON (JSONL) events. - `tool_use`: Tool call requests with arguments. - `tool_result`: Output from executed tools. - `error`: Non-fatal warnings and system errors. - - `result`: Final outcome with aggregated statistics. + - `result`: Final outcome with aggregated statistics and per-model token usage + breakdowns. ## Exit codes diff --git a/docs/cli/model-routing.md b/docs/cli/model-routing.md index 1f7ba5da09..3c7bd65bc5 100644 --- a/docs/cli/model-routing.md +++ b/docs/cli/model-routing.md @@ -26,6 +26,20 @@ policies. the CLI will use an available fallback model for the current turn or the remainder of the session. +### Local Model Routing (Experimental) + +Gemini CLI supports using a local model for routing decisions. When configured, +Gemini CLI will use a locally-running **Gemma** model to make routing decisions +(instead of sending routing decisions to a hosted model). This feature can help +reduce costs associated with hosted model usage while offering similar routing +decision latency and quality. + +In order to use this feature, the local Gemma model **must** be served behind a +Gemini API and accessible via HTTP at an endpoint configured in `settings.json`. + +For more details on how to configure local model routing, see +[Local Model Routing](../core/local-model-routing.md). + ### Model selection precedence The model used by Gemini CLI is determined by the following order of precedence: @@ -38,5 +52,8 @@ The model used by Gemini CLI is determined by the following order of precedence: 3. **`model.name` in `settings.json`:** If neither of the above are set, the model specified in the `model.name` property of your `settings.json` file will be used. -4. **Default model:** If none of the above are set, the default model will be +4. **Local model (experimental):** If the Gemma local model router is enabled + in your `settings.json` file, the CLI will use the local Gemma model + (instead of Gemini models) to route the request to an appropriate model. +5. **Default model:** If none of the above are set, the default model will be used. The default model is `auto` diff --git a/docs/cli/model-steering.md b/docs/cli/model-steering.md new file mode 100644 index 0000000000..26ff4e1209 --- /dev/null +++ b/docs/cli/model-steering.md @@ -0,0 +1,80 @@ +# Model steering (experimental) + +Model steering lets you provide real-time guidance and feedback to Gemini CLI +while it is actively executing a task. This lets you correct course, add missing +context, or skip unnecessary steps without having to stop and restart the agent. + + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. + +Model steering is particularly useful during complex [Plan Mode](./plan-mode.md) +workflows or long-running subagent executions where you want to ensure the agent +stays on the right track. + +## Enabling model steering + +Model steering is an experimental feature and is disabled by default. You can +enable it using the `/settings` command or by updating your `settings.json` +file. + +1. Type `/settings` in the Gemini CLI. +2. Search for **Model Steering**. +3. Set the value to **true**. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "experimental": { + "modelSteering": true + } +} +``` + +## Using model steering + +When model steering is enabled, Gemini CLI treats any text you type while the +agent is working as a steering hint. + +1. Start a task (for example, "Refactor the database service"). +2. While the agent is working (the spinner is visible), type your feedback in + the input box. +3. Press **Enter**. + +Gemini CLI acknowledges your hint with a brief message and injects it directly +into the model's context for the very next turn. The model then re-evaluates its +current plan and adjusts its actions accordingly. + +### Common use cases + +You can use steering hints to guide the model in several ways: + +- **Correcting a path:** "Actually, the utilities are in `src/common/utils`." +- **Skipping a step:** "Skip the unit tests for now and just focus on the + implementation." +- **Adding context:** "The `User` type is defined in `packages/core/types.ts`." +- **Redirecting the effort:** "Stop searching the codebase and start drafting + the plan now." +- **Handling ambiguity:** "Use the existing `Logger` class instead of creating a + new one." + +## How it works + +When you submit a steering hint, Gemini CLI performs the following actions: + +1. **Immediate acknowledgment:** It uses a small, fast model to generate a + one-sentence acknowledgment so you know your hint was received. +2. **Context injection:** It prepends an internal instruction to your hint that + tells the main agent to: + - Re-evaluate the active plan. + - Classify the update (for example, as a new task or extra context). + - Apply minimal-diff changes to affected tasks. +3. **Real-time update:** The hint is delivered to the agent at the beginning of + its next turn, ensuring the most immediate course correction possible. + +## Next steps + +- Tackle complex tasks with [Plan Mode](./plan-mode.md). +- Build custom [Agent Skills](./skills.md). diff --git a/docs/cli/model.md b/docs/cli/model.md index 62bfcf5b0b..b85f597e08 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -5,7 +5,9 @@ used by Gemini CLI, giving you more control over your results. Use **Pro** models for complex tasks and reasoning, **Flash** models for high speed results, or the (recommended) **Auto** setting to choose the best model for your tasks. -> **Note:** The `/model` command (and the `--model` flag) does not override the + +> [!NOTE] +> The `/model` command (and the `--model` flag) does not override the > model used by sub-agents. Consequently, even when using the `/model` flag you > may see other models used in your model usage reports. @@ -19,24 +21,15 @@ Use the following command in Gemini CLI: Running this command will open a dialog with your options: -| Option | Description | Models | -| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- | -| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) | -| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash | -| Manual | Select a specific model. | Any available model. | +| Option | Description | Models | +| ----------------- | -------------------------------------------------------------- | -------------------------------------------- | +| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview, gemini-3-flash-preview | +| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash | +| Manual | Select a specific model. | Any available model. | We recommend selecting one of the above **Auto** options. However, you can select **Manual** to select a specific model from those available. -### Gemini 3 and preview features - -> **Note:** Gemini 3 is not currently available on all account types. To learn -> more about Gemini 3 access, refer to -> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md). - -To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable -[**Preview Features** by using the `settings` command](../cli/settings.md). - You can also use the `--model` flag to specify a particular Gemini model on startup. For more details, refer to the [configuration documentation](../reference/configuration.md). diff --git a/docs/cli/notifications.md b/docs/cli/notifications.md new file mode 100644 index 0000000000..8cff6c54f3 --- /dev/null +++ b/docs/cli/notifications.md @@ -0,0 +1,59 @@ +# Notifications (experimental) + +Gemini CLI can send system notifications to alert you when a session completes +or when it needs your attention, such as when it's waiting for you to approve a +tool call. + + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. + +Notifications are particularly useful when running long-running tasks or using +[Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini +CLI works in the background. + +## Requirements + +Currently, system notifications are only supported on macOS. + +### Terminal support + +The CLI uses the OSC 9 terminal escape sequence to trigger system notifications. +This is supported by several modern terminal emulators. If your terminal does +not support OSC 9 notifications, Gemini CLI falls back to a system alert sound +to get your attention. + +## Enable notifications + +Notifications are disabled by default. You can enable them using the `/settings` +command or by updating your `settings.json` file. + +1. Open the settings dialog by typing `/settings` in an interactive session. +2. Navigate to the **General** category. +3. Toggle the **Enable Notifications** setting to **On**. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "general": { + "enableNotifications": true + } +} +``` + +## Types of notifications + +Gemini CLI sends notifications for the following events: + +- **Action required:** Triggered when the model is waiting for user input or + tool approval. This helps you know when the CLI has paused and needs you to + intervene. +- **Session complete:** Triggered when a session finishes successfully. This is + useful for tracking the completion of automated tasks. + +## Next steps + +- Start planning with [Plan Mode](./plan-mode.md). +- Configure your experience with other [settings](./settings.md). diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 654b54dbc9..5299bb3463 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -1,109 +1,111 @@ -# Plan Mode (experimental) +# Plan Mode Plan Mode is a read-only environment for architecting robust solutions before -implementation. It allows you to: +implementation. With Plan Mode, you can: - **Research:** Explore the project in a read-only state to prevent accidental changes. - **Design:** Understand problems, evaluate trade-offs, and choose a solution. - **Plan:** Align on an execution strategy before any code is modified. -> **Note:** This is a preview feature currently under active development. Your -> feedback is invaluable as we refine this feature. If you have ideas, -> suggestions, or encounter issues: -> -> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on -> GitHub. -> - Use the **/bug** command within Gemini CLI to file an issue. +Plan Mode is enabled by default. You can manage this setting using the +`/settings` command. -- [Enabling Plan Mode](#enabling-plan-mode) -- [How to use Plan Mode](#how-to-use-plan-mode) - - [Entering Plan Mode](#entering-plan-mode) - - [Planning Workflow](#planning-workflow) - - [Exiting Plan Mode](#exiting-plan-mode) -- [Tool Restrictions](#tool-restrictions) - - [Customizing Planning with Skills](#customizing-planning-with-skills) - - [Customizing Policies](#customizing-policies) - - [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode) - - [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode) - - [Custom Plan Directory and Policies](#custom-plan-directory-and-policies) -- [Automatic Model Routing](#automatic-model-routing) +## How to enter Plan Mode -## Enabling Plan Mode +Plan Mode integrates seamlessly into your workflow, letting you switch between +planning and execution as needed. -To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the -following to your `settings.json`: +You can either configure Gemini CLI to start in Plan Mode by default or enter +Plan Mode manually during a session. -```json -{ - "experimental": { - "plan": true - } -} -``` +### Launch in Plan Mode -## How to use Plan Mode +To start Gemini CLI directly in Plan Mode by default: -### Entering Plan Mode +1. Use the `/settings` command. +2. Set **Default Approval Mode** to `Plan`. -You can configure Gemini CLI to start in Plan Mode by default or enter it -manually during a session. +To launch Gemini CLI in Plan Mode once: -- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by - default: - 1. Type `/settings` in the CLI. - 2. Search for **Default Approval Mode**. - 3. Set the value to **Plan**. +1. Use `gemini --approval-mode=plan` when launching Gemini CLI. - Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually - update: +### Enter Plan Mode manually - ```json - { - "general": { - "defaultApprovalMode": "plan" - } - } - ``` +To start Plan Mode while using Gemini CLI: -- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes - (`Default` -> `Auto-Edit` -> `Plan`). - - > **Note:** Plan Mode is automatically removed from the rotation when Gemini - > CLI is actively processing or showing confirmation dialogs. +- **Keyboard shortcut:** Press `Shift+Tab` to cycle through approval modes + (`Default` -> `Auto-Edit` -> `Plan`). Plan Mode is automatically removed from + the rotation when Gemini CLI is actively processing or showing confirmation + dialogs. - **Command:** Type `/plan` in the input box. -- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI then - calls the [`enter_plan_mode`] tool to switch modes. - > **Note:** This tool is not available when Gemini CLI is in [YOLO mode]. +- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI + calls the + [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode) tool + to switch modes. This tool is not available when Gemini CLI is in + [YOLO mode](../reference/configuration.md#command-line-arguments). -### Planning Workflow +## How to use Plan Mode -1. **Explore & Analyze:** Analyze requirements and use read-only tools to map - the codebase and validate assumptions. For complex tasks, identify at least - two viable implementation approaches. -2. **Consult:** Present a summary of the identified approaches via [`ask_user`] - to obtain a selection. For simple or canonical tasks, this step may be - skipped. -3. **Draft:** Once an approach is selected, write a detailed implementation - plan to the plans directory. -4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan - and formally request approval. - - **Approve:** Exit Plan Mode and start implementation. - - **Iterate:** Provide feedback to refine the plan. +Plan Mode lets you collaborate with Gemini CLI to design a solution before +Gemini CLI takes action. + +1. **Provide a goal:** Start by describing what you want to achieve. Gemini CLI + will then enter Plan Mode (if it's not already) to research the task. +2. **Review research and provide input:** As Gemini CLI analyzes your codebase, + it may ask you questions or present different implementation options using + [`ask_user`](../tools/ask-user.md). Provide your preferences to help guide + the design. +3. **Review the plan:** Once Gemini CLI has a proposed strategy, it creates a + detailed implementation plan as a Markdown file in your plans directory. + - **View:** You can open and read this file to understand the proposed + changes. + - **Edit:** Press `Ctrl+X` to open the plan directly in your configured + external editor. + +4. **Approve or iterate:** Gemini CLI will present the finalized plan for your + approval. + - **Approve:** If you're satisfied with the plan, approve it to start the + implementation immediately: **Yes, automatically accept edits** or **Yes, + manually accept edits**. + - **Iterate:** If the plan needs adjustments, provide feedback in the input + box or [edit the plan file directly](#collaborative-plan-editing). Gemini + CLI will refine the strategy and update the plan. + - **Cancel:** You can cancel your plan with `Esc`. For more complex or specialized planning tasks, you can -[customize the planning workflow with skills](#customizing-planning-with-skills). +[customize the planning workflow with skills](#custom-planning-with-skills). -### Exiting Plan Mode +### Collaborative plan editing -To exit Plan Mode, you can: +You can collaborate with Gemini CLI by making direct changes or leaving comments +in the implementation plan. This is often faster and more precise than +describing complex changes in natural language. -- **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode. +1. **Open the plan:** Press `Ctrl+X` when Gemini CLI presents a plan for + review. +2. **Edit or comment:** The plan opens in your configured external editor (for + example, VS Code or Vim). You can: + - **Modify steps:** Directly reorder, delete, or rewrite implementation + steps. + - **Leave comments:** Add inline questions or feedback (for example, "Wait, + shouldn't we use the existing `Logger` class here?"). +3. **Save and close:** Save your changes and close the editor. +4. **Review and refine:** Gemini CLI automatically detects the changes, reviews + your comments, and adjusts the implementation strategy. It then presents the + refined plan for your final approval. -- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the - finalized plan for your approval. +## How to exit Plan Mode + +You can exit Plan Mode at any time, whether you have finalized a plan or want to +switch back to another mode. + +- **Approve a plan:** When Gemini CLI presents a finalized plan, approving it + automatically exits Plan Mode and starts the implementation. +- **Keyboard shortcut:** Press `Shift+Tab` to cycle to the desired mode. +- **Natural language:** Ask Gemini CLI to "exit plan mode" or "stop planning." ## Tool Restrictions @@ -111,24 +113,40 @@ Plan Mode enforces strict safety policies to prevent accidental changes. These are the only allowed tools: -- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`] -- **Search:** [`grep_search`], [`google_web_search`] -- **Interaction:** [`ask_user`] -- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`, - `postgres_read_schema`) are allowed. -- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md` +- **FileSystem (Read):** + [`read_file`](../tools/file-system.md#2-read_file-readfile), + [`list_directory`](../tools/file-system.md#1-list_directory-readfolder), + [`glob`](../tools/file-system.md#4-glob-findfiles) +- **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext), + [`google_web_search`](../tools/web-search.md), + [`get_internal_docs`](../tools/internal-docs.md) +- **Research Subagents:** + [`codebase_investigator`](../core/subagents.md#codebase-investigator), + [`cli_help`](../core/subagents.md#cli-help-agent) +- **Interaction:** [`ask_user`](../tools/ask-user.md) +- **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for + example, `github_read_issue`, `postgres_read_schema`) are allowed. +- **Planning (Write):** + [`write_file`](../tools/file-system.md#3-write_file-writefile) and + [`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md` files in the `~/.gemini/tmp///plans/` directory or your [custom plans directory](#custom-plan-directory-and-policies). -- **Memory:** [`save_memory`] -- **Skills:** [`activate_skill`] (allows loading specialized instructions and - resources in a read-only manner) +- **Memory:** [`save_memory`](../tools/memory.md) +- **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized + instructions and resources in a read-only manner) -### Customizing Planning with Skills +## Customization and best practices -You can use [Agent Skills](./skills.md) to customize how Gemini CLI approaches -planning for specific types of tasks. When a skill is activated during Plan -Mode, its specialized instructions and procedural workflows will guide the -research, design and planning phases. +Plan Mode is secure by default, but you can adapt it to fit your specific +workflows. You can customize how Gemini CLI plans by using skills, adjusting +safety policies, changing where plans are stored, or adding hooks. + +### Custom planning with skills + +You can use [Agent Skills](../cli/skills.md) to customize how Gemini CLI +approaches planning for specific types of tasks. When a skill is activated +during Plan Mode, its specialized instructions and procedural workflows will +guide the research, design, and planning phases. For example: @@ -143,12 +161,34 @@ To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the `` skill to plan..." or Gemini CLI may autonomously activate it based on the task description. -### Customizing Policies +### Custom policies -Plan Mode's default tool restrictions are managed by the [policy engine] and -defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1) -enforces the read-only state, but you can customize these rules by creating your -own policies in your `~/.gemini/policies/` directory (Tier 2). +Plan Mode's default tool restrictions are managed by the +[policy engine](../reference/policy-engine.md) and defined in the built-in +[`plan.toml`] file. The built-in policy (Tier 1) enforces the read-only state, +but you can customize these rules by creating your own policies in your +`~/.gemini/policies/` directory (Tier 2). + +#### Global vs. mode-specific rules + +As described in the +[policy engine documentation](../reference/policy-engine.md#approval-modes), any +rule that does not explicitly specify `modes` is considered "always active" and +will apply to Plan Mode as well. + +If you want a rule to apply to other modes but _not_ to Plan Mode, you must +explicitly specify the target modes. For example, to allow `npm test` in default +and Auto-Edit modes but not in Plan Mode: + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = "npm test" +decision = "allow" +priority = 100 +# By omitting "plan", this rule will not be active in Plan Mode. +modes = ["default", "autoEdit"] +``` #### Example: Automatically approve read-only MCP tools @@ -167,10 +207,13 @@ priority = 100 modes = ["plan"] ``` +For more information on how the policy engine works, see the +[policy engine](../reference/policy-engine.md) docs. + #### Example: Allow git commands in Plan Mode -This rule allows you to check the repository status and see changes while in -Plan Mode. +This rule lets you check the repository status and see changes while in Plan +Mode. `~/.gemini/policies/git-research.toml` @@ -183,16 +226,20 @@ priority = 100 modes = ["plan"] ``` -#### Example: Enable research subagents in Plan Mode +#### Example: Enable custom subagents in Plan Mode -You can enable experimental research [subagents] like `codebase_investigator` to -help gather architecture details during the planning phase. +Built-in research [subagents](../core/subagents.md) like +[`codebase_investigator`](../core/subagents.md#codebase-investigator) and +[`cli_help`](../core/subagents.md#cli-help-agent) are enabled by default in Plan +Mode. You can enable additional +[custom subagents](../core/subagents.md#creating-custom-subagents) by adding a +rule to your policy. `~/.gemini/policies/research-subagents.toml` ```toml [[rule]] -toolName = "codebase_investigator" +toolName = "my_custom_subagent" decision = "allow" priority = 100 modes = ["plan"] @@ -201,10 +248,7 @@ modes = ["plan"] Tell Gemini CLI it can use these tools in your prompt, for example: _"You can check ongoing changes in git."_ -For more information on how the policy engine works, see the [policy engine] -docs. - -### Custom Plan Directory and Policies +### Custom plan directory and policies By default, planning artifacts are stored in a managed temporary directory outside your project: `~/.gemini/tmp///plans/`. @@ -228,10 +272,11 @@ locations defined within a project's workspace cannot be used to escape and overwrite sensitive files elsewhere. Any user-configured directory must reside within the project boundary. -Using a custom directory requires updating your [policy engine] configurations -to allow `write_file` and `replace` in that specific location. For example, to -allow writing to the `.gemini/plans` directory within your project, create a -policy file at `~/.gemini/policies/plan-custom-directory.toml`: +Using a custom directory requires updating your +[policy engine](../reference/policy-engine.md) configurations to allow +`write_file` and `replace` in that specific location. For example, to allow +writing to the `.gemini/plans` directory within your project, create a policy +file at `~/.gemini/policies/plan-custom-directory.toml`: ```toml [[rule]] @@ -244,10 +289,136 @@ modes = ["plan"] argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"" ``` +### Using hooks with Plan Mode + +You can use the [hook system](../hooks/writing-hooks.md) to automate parts of +the planning workflow or enforce additional checks when Gemini CLI transitions +into or out of Plan Mode. + +Hooks such as `BeforeTool` or `AfterTool` can be configured to intercept the +`enter_plan_mode` and `exit_plan_mode` tool calls. + +> [!WARNING] When hooks are triggered by **tool executions**, they do **not** +> run when you manually toggle Plan Mode using the `/plan` command or the +> `Shift+Tab` keyboard shortcut. If you need hooks to execute on mode changes, +> ensure the transition is initiated by the agent (e.g., by asking "start a plan +> for..."). + +#### Example: Archive approved plans to GCS (`AfterTool`) + +If your organizational policy requires a record of all execution plans, you can +use an `AfterTool` hook to securely copy the plan artifact to Google Cloud +Storage whenever Gemini CLI exits Plan Mode to start the implementation. + +**`.gemini/hooks/archive-plan.sh`:** + +```bash +#!/usr/bin/env bash +# Extract the plan path from the tool input JSON +plan_path=$(jq -r '.tool_input.plan_path // empty') + +if [ -f "$plan_path" ]; then + # Generate a unique filename using a timestamp + filename="$(date +%s)_$(basename "$plan_path")" + + # Upload the plan to GCS in the background so it doesn't block the CLI + gsutil cp "$plan_path" "gs://my-audit-bucket/gemini-plans/$filename" > /dev/null 2>&1 & +fi + +# AfterTool hooks should generally allow the flow to continue +echo '{"decision": "allow"}' +``` + +To register this `AfterTool` hook, add it to your `settings.json`: + +```json +{ + "hooks": { + "AfterTool": [ + { + "matcher": "exit_plan_mode", + "hooks": [ + { + "name": "archive-plan", + "type": "command", + "command": "./.gemini/hooks/archive-plan.sh" + } + ] + } + ] + } +} +``` + +## Commands + +- **`/plan copy`**: Copy the currently approved plan to your clipboard. + +## Planning workflows + +Plan Mode provides building blocks for structured research and design. These are +implemented as [extensions](../extensions/index.md) using core planning tools +like [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode), +[`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode), and +[`ask_user`](../tools/ask-user.md). + +### Built-in planning workflow + +The built-in planner uses an adaptive workflow to analyze your project, consult +you on trade-offs via [`ask_user`](../tools/ask-user.md), and draft a plan for +your approval. + +### Custom planning workflows + +You can install or create specialized planners to suit your workflow. + +#### Conductor + +[Conductor] is designed for spec-driven development. It organizes work into +"tracks" and stores persistent artifacts in your project's `conductor/` +directory: + +- **Automate transitions:** Switches to read-only mode via + [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode). +- **Streamline decisions:** Uses [`ask_user`](../tools/ask-user.md) for + architectural choices. +- **Maintain project context:** Stores artifacts in the project directory using + [custom plan directory and policies](#custom-plan-directory-and-policies). +- **Handoff execution:** Transitions to implementation via + [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode). + +#### Build your own + +Since Plan Mode is built on modular building blocks, you can develop your own +custom planning workflow as an [extensions](../extensions/index.md). By +leveraging core tools and [custom policies](#custom-policies), you can define +how Gemini CLI researches and stores plans for your specific domain. + +To build a custom planning workflow, you can use: + +- **Tool usage:** Use core tools like + [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode), + [`ask_user`](../tools/ask-user.md), and + [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode) to + manage the research and design process. +- **Customization:** Set your own storage locations and policy rules using + [custom plan directories](#custom-plan-directory-and-policies) and + [custom policies](#custom-policies). + + +> [!TIP] +> Use [Conductor] as a reference when building your own custom +> planning workflow. + +By using Plan Mode as its execution environment, your custom methodology can +enforce read-only safety during the design phase while benefiting from +high-reasoning model routing. + ## Automatic Model Routing -When using an [**auto model**], Gemini CLI automatically optimizes [**model -routing**] based on the current phase of your task: +When using an [auto model](../reference/configuration.md#model), Gemini CLI +automatically optimizes [model routing](../cli/telemetry.md#model-routing) based +on the current phase of your task: 1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a high-reasoning **Pro** model to ensure robust architectural decisions and @@ -270,23 +441,46 @@ performance. You can disable this automatic switching in your settings: } ``` -[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder -[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile -[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext -[`write_file`]: /docs/tools/file-system.md#3-write_file-writefile -[`glob`]: /docs/tools/file-system.md#4-glob-findfiles -[`google_web_search`]: /docs/tools/web-search.md -[`replace`]: /docs/tools/file-system.md#6-replace-edit -[MCP tools]: /docs/tools/mcp-server.md -[`save_memory`]: /docs/tools/memory.md -[`activate_skill`]: /docs/cli/skills.md -[subagents]: /docs/core/subagents.md -[policy engine]: /docs/reference/policy-engine.md -[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode -[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode -[`ask_user`]: /docs/tools/ask-user.md -[YOLO mode]: /docs/reference/configuration.md#command-line-arguments +## Cleanup + +By default, Gemini CLI automatically cleans up old session data, including all +associated plan files and task trackers. + +- **Default behavior:** Sessions (and their plans) are retained for **30 days**. +- **Configuration:** You can customize this behavior via the `/settings` command + (search for **Session Retention**) or in your `settings.json` file. See + [session retention](../cli/session-management.md#session-retention) for more + details. + +Manual deletion also removes all associated artifacts: + +- **Command Line:** Use `gemini --delete-session `. +- **Session Browser:** Press `/resume`, navigate to a session, and press `x`. + +If you use a [custom plans directory](#custom-plan-directory-and-policies), +those files are not automatically deleted and must be managed manually. + +## Non-interactive execution + +When running Gemini CLI in non-interactive environments (such as headless +scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows: + +- **Automatic transitions:** The policy engine automatically approves the + `enter_plan_mode` and `exit_plan_mode` tools without prompting for user + confirmation. +- **Automated implementation:** When exiting Plan Mode to execute the plan, + Gemini CLI automatically switches to + [YOLO mode](../reference/policy-engine.md#approval-modes) instead of the + standard Default mode. This allows the CLI to execute the implementation steps + automatically without hanging on interactive tool approvals. + +**Example:** + +```bash +gemini --approval-mode plan -p "Analyze telemetry and suggest improvements" +``` + [`plan.toml`]: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml -[auto model]: /docs/reference/configuration.md#model-settings -[model routing]: /docs/cli/telemetry.md#model-routing +[Conductor]: https://github.com/gemini-cli-extensions/conductor +[open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 392c71a176..b34433a878 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -50,17 +50,118 @@ Cross-platform sandboxing with complete process isolation. **Note**: Requires building the sandbox image locally or using a published image from your organization's registry. +### 3. Windows Native Sandbox (Windows only) + +... **Troubleshooting and Side Effects:** + +The Windows Native sandbox uses the `icacls` command to set a "Low Mandatory +Level" on files and directories it needs to write to. + +- **Persistence**: These integrity level changes are persistent on the + filesystem. Even after the sandbox session ends, files created or modified by + the sandbox will retain their "Low" integrity level. +- **Manual Reset**: If you need to reset the integrity level of a file or + directory, you can use: + ```powershell + icacls "C:\path\to\dir" /setintegritylevel Medium + ``` +- **System Folders**: The sandbox manager automatically skips setting integrity + levels on system folders (like `C:\Windows`) for safety. + +### 4. gVisor / runsc (Linux only) + +Strongest isolation available: runs containers inside a user-space kernel via +[gVisor](https://github.com/google/gvisor). gVisor intercepts all container +system calls and handles them in a sandboxed kernel written in Go, providing a +strong security barrier between AI operations and the host OS. + +**Prerequisites:** + +- Linux (gVisor supports Linux only) +- Docker installed and running +- gVisor/runsc runtime configured + +When you set `sandbox: "runsc"`, Gemini CLI runs +`docker run --runtime=runsc ...` to execute containers with gVisor isolation. +runsc is not auto-detected; you must specify it explicitly (e.g. +`GEMINI_SANDBOX=runsc` or `sandbox: "runsc"`). + +To set up runsc: + +1. Install the runsc binary. +2. Configure the Docker daemon to use the runsc runtime. +3. Verify the installation. + +### 4. LXC/LXD (Linux only, experimental) + +Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC +containers run a complete Linux system with `systemd`, `snapd`, and other system +services. This is ideal for tools that don't work in standard Docker containers, +such as Snapcraft and Rockcraft. + +**Prerequisites**: + +- Linux only. +- LXC/LXD must be installed (`snap install lxd` or `apt install lxd`). +- A container must be created and running before starting Gemini CLI. Gemini + does **not** create the container automatically. + +**Quick setup**: + +```bash +# Initialize LXD (first time only) +lxd init --auto + +# Create and start an Ubuntu container +lxc launch ubuntu:24.04 gemini-sandbox + +# Enable LXC sandboxing +export GEMINI_SANDBOX=lxc +gemini -p "build the project" +``` + +**Custom container name**: + +```bash +export GEMINI_SANDBOX=lxc +export GEMINI_SANDBOX_IMAGE=my-snapcraft-container +gemini -p "build the snap" +``` + +**Limitations**: + +- Linux only (LXC is not available on macOS or Windows). +- The container must already exist and be running. +- The workspace directory is bind-mounted into the container at the same + absolute path — the path must be writable inside the container. +- Used with tools like Snapcraft or Rockcraft that require a full system. + ## Quickstart ```bash # Enable sandboxing with command flag gemini -s -p "analyze the code structure" +``` -# Use environment variable +**Use environment variable** + +**macOS/Linux** + +```bash export GEMINI_SANDBOX=true gemini -p "run the test suite" +``` -# Configure in settings.json +**Windows (PowerShell)** + +```powershell +$env:GEMINI_SANDBOX="true" +gemini -p "run the test suite" +``` + +**Configure in settings.json** + +```json { "tools": { "sandbox": "docker" @@ -73,7 +174,8 @@ gemini -p "run the test suite" ### Enable sandboxing (in order of precedence) 1. **Command flag**: `-s` or `--sandbox` -2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec` +2. **Environment variable**: + `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc` 3. **Settings file**: `"sandbox": true` in the `tools` object of your `settings.json` file (e.g., `{"tools": {"sandbox": true}}`). @@ -99,26 +201,51 @@ use cases. To disable SELinux labeling for volume mounts, you can set the following: +**macOS/Linux** + ```bash export SANDBOX_FLAGS="--security-opt label=disable" ``` +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_FLAGS="--security-opt label=disable" +``` + Multiple flags can be provided as a space-separated string: +**macOS/Linux** + ```bash export SANDBOX_FLAGS="--flag1 --flag2=value" ``` +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_FLAGS="--flag1 --flag2=value" +``` + ## Linux UID/GID handling The sandbox automatically handles user permissions on Linux. Override these permissions with: +**macOS/Linux** + ```bash export SANDBOX_SET_UID_GID=true # Force host UID/GID export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping ``` +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_SET_UID_GID="true" # Force host UID/GID +$env:SANDBOX_SET_UID_GID="false" # Disable UID/GID mapping +``` + ## Troubleshooting ### Common issues @@ -144,9 +271,11 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping DEBUG=1 gemini -s -p "debug command" ``` -**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect -gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli -specific debug settings. + +> [!NOTE] +> If you have `DEBUG=true` in a project's `.env` file, it won't affect +> gemini-cli due to automatic exclusion. Use `.gemini/.env` files for +> gemini-cli specific debug settings. ### Inspect sandbox diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md index 8e68014318..05212821ba 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -70,6 +70,15 @@ Browser**: /resume ``` +When typing `/resume` (or `/chat`) in slash completion, commands are grouped +under titled separators: + +- `-- auto --` (session browser) + - `list` is selectable and opens the session browser +- `-- checkpoints --` (manual tagged checkpoint commands) + +Unique prefixes such as `/resum` and `/cha` resolve to the same grouped menu. + The Session Browser provides an interactive interface where you can perform the following actions: @@ -82,6 +91,27 @@ following actions: created in a different folder, Gemini CLI confirms before continuing there. - **Esc:** Press **Esc** to exit the Session Browser. +### Manual chat checkpoints + +For named branch points inside a session, use chat checkpoints: + +```text +/resume save decision-point +/resume list +/resume resume decision-point +``` + +Compatibility aliases: + +- `/chat ...` works for the same commands. +- `/resume checkpoints ...` also remains supported during migration. + +## Parallel sessions with Git worktrees + +When working on multiple tasks at once, you can use +[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of +the codebase. This prevents changes in one session from colliding with another. + ## Managing sessions You can list and delete sessions to keep your history organized and manage disk @@ -132,27 +162,36 @@ session lengths. ### Session retention -To prevent your history from growing indefinitely, enable automatic cleanup -policies in your settings. +By default, Gemini CLI automatically cleans up old session data to prevent your +history from growing indefinitely. When a session is deleted, Gemini CLI also +removes all associated data, including implementation plans, task trackers, tool +outputs, and activity logs. + +The default policy is to **retain sessions for 30 days**. + +#### Configuration + +You can customize these policies using the `/settings` command or by manually +editing your `settings.json` file: ```json { "general": { "sessionRetention": { "enabled": true, - "maxAge": "30d", // Keep sessions for 30 days - "maxCount": 50 // Keep the 50 most recent sessions + "maxAge": "30d", + "maxCount": 50 } } } ``` - **`enabled`**: (boolean) Master switch for session cleanup. Defaults to - `false`. + `true`. - **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d", - "4w"). Sessions older than this are deleted. + "4w"). Sessions older than this are deleted. Defaults to `"30d"`. - **`maxCount`**: (number) Maximum number of sessions to retain. The oldest - sessions exceeding this count are deleted. + sessions exceeding this count are deleted. Defaults to undefined (unlimited). - **`minRetention`**: (string) Minimum retention period (safety limit). Defaults to `"1d"`. Sessions newer than this period are never deleted by automatic cleanup. diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 8adccba6ae..2a4b5963ce 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -11,7 +11,9 @@ locations: - **User settings**: `~/.gemini/settings.json` - **Workspace settings**: `your-project/.gemini/settings.json` -Note: Workspace settings override user settings. + +> [!IMPORTANT] +> Workspace settings override user settings. ## Settings reference @@ -22,18 +24,19 @@ they appear in the UI. ### General -| UI Label | Setting | Description | Default | -| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | -| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | -| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | -| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | -| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | -| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | -| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | -| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | -| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | -| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` | +| UI Label | Setting | Description | Default | +| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | +| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` | +| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | +| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | +| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | +| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | +| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | +| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | +| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | +| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | ### Output @@ -54,24 +57,26 @@ they appear in the UI. | Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` | | Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` | | Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` | +| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` | | Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` | | Hide Banner | `ui.hideBanner` | Hide the application banner | `false` | | Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` | -| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` | +| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` | | Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | | Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | -| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` | +| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | | Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | | Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | | Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | | Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | | Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | -| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` | +| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` | | Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | | Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | | Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | | Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | | Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` | +| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | | Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | ### IDE @@ -80,22 +85,35 @@ they appear in the UI. | -------- | ------------- | ---------------------------- | ------- | | IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` | +### Billing + +| UI Label | Setting | Description | Default | +| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` | + ### Model -| UI Label | Setting | Description | Default | -| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | -| Model | `model.name` | The Gemini model to use for conversations. | `undefined` | -| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | -| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | -| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | -| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | +| UI Label | Setting | Description | Default | +| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | +| Model | `model.name` | The Gemini model to use for conversations. | `undefined` | +| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | +| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | +| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | +| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | + +### Agents + +| UI Label | Setting | Description | Default | +| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------- | ------- | +| Confirm Sensitive Actions | `agents.browser.confirmSensitiveActions` | Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script). | `false` | +| Block File Uploads | `agents.browser.blockFileUploads` | Hard-block file upload requests from the browser agent. | `false` | ### Context | UI Label | Setting | Description | Default | | ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` | -| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | +| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` | | Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` | | Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` | | Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` | @@ -106,6 +124,8 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Sandbox Allowed Paths | `tools.sandboxAllowedPaths` | List of additional paths that the sandbox is allowed to access. | `[]` | +| Sandbox Network Access | `tools.sandboxNetworkAccess` | Whether the sandbox is allowed to access the network. | `false` | | Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | | Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | | Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | @@ -116,8 +136,11 @@ they appear in the UI. | UI Label | Setting | Description | Default | | ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| Tool Sandboxing | `security.toolSandboxing` | Experimental tool-level sandboxing (implementation in progress). | `false` | | Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | +| Disable Always Allow | `security.disableAlwaysAllow` | Disable "Always allow" options in tool confirmation dialogs. | `false` | | Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | +| Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` | | Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | | Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` | | Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` | @@ -135,11 +158,14 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | +| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | | Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | -| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` | +| Plan | `experimental.plan` | Enable Plan Mode. | `true` | | Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | | Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | +| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` | +| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | ### Skills diff --git a/docs/cli/skills.md b/docs/cli/skills.md index d3e8d4e84f..73e5eb66eb 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -63,8 +63,10 @@ Use the `/skills` slash command to view and manage available expertise: - `/skills enable `: Re-enables a disabled skill. - `/skills reload`: Refreshes the list of discovered skills from all tiers. -_Note: `/skills disable` and `/skills enable` default to the `user` scope. Use -`--scope workspace` to manage workspace-specific settings._ + +> [!NOTE] +> `/skills disable` and `/skills enable` default to the `user` scope. Use +> `--scope workspace` to manage workspace-specific settings. ### From the Terminal diff --git a/docs/cli/system-prompt.md b/docs/cli/system-prompt.md index b1ff43e3fd..c249d55cec 100644 --- a/docs/cli/system-prompt.md +++ b/docs/cli/system-prompt.md @@ -14,7 +14,9 @@ core instructions will apply unless you include them yourself. This feature is intended for advanced users who need to enforce strict, project-specific behavior or create a customized persona. -> Tip: You can export the current default system prompt to a file first, review + +> [!TIP] +> You can export the current default system prompt to a file first, review > it, and then selectively modify or replace it (see > [“Export the default prompt”](#export-the-default-prompt-recommended)). diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index b04d2e0173..dd13d5eb82 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -1,81 +1,39 @@ # Observability with OpenTelemetry -Learn how to enable and setup OpenTelemetry for Gemini CLI. +Observability is the key to turning experimental AI into reliable software. +Gemini CLI provides built-in support for OpenTelemetry, transforming every agent +interaction into a rich stream of logs, metrics, and traces. This three-pillar +approach gives you the high-fidelity visibility needed to understand agent +behavior, optimize performance, and ensure reliability across your entire +workflow. -- [Observability with OpenTelemetry](#observability-with-opentelemetry) - - [Key benefits](#key-benefits) - - [OpenTelemetry integration](#opentelemetry-integration) - - [Configuration](#configuration) - - [Google Cloud telemetry](#google-cloud-telemetry) - - [Prerequisites](#prerequisites) - - [Authenticating with CLI Credentials](#authenticating-with-cli-credentials) - - [Direct export (recommended)](#direct-export-recommended) - - [Collector-based export (advanced)](#collector-based-export-advanced) - - [Monitoring Dashboards](#monitoring-dashboards) - - [Local telemetry](#local-telemetry) - - [File-based output (recommended)](#file-based-output-recommended) - - [Collector-based export (advanced)](#collector-based-export-advanced-1) - - [Logs and metrics](#logs-and-metrics) - - [Logs](#logs) - - [Sessions](#sessions) - - [Approval Mode](#approval-mode) - - [Tools](#tools) - - [Files](#files) - - [API](#api) - - [Model routing](#model-routing) - - [Chat and streaming](#chat-and-streaming) - - [Resilience](#resilience) - - [Extensions](#extensions) - - [Agent runs](#agent-runs) - - [IDE](#ide) - - [UI](#ui) - - [Metrics](#metrics) - - [Custom](#custom) - - [Sessions](#sessions-1) - - [Tools](#tools-1) - - [API](#api-1) - - [Token usage](#token-usage) - - [Files](#files-1) - - [Chat and streaming](#chat-and-streaming-1) - - [Model routing](#model-routing-1) - - [Agent runs](#agent-runs-1) - - [UI](#ui-1) - - [Performance](#performance) - - [GenAI semantic convention](#genai-semantic-convention) - -## Key benefits - -- **🔍 Usage analytics**: Understand interaction patterns and feature adoption - across your team -- **⚡ Performance monitoring**: Track response times, token consumption, and - resource utilization -- **🐛 Real-time debugging**: Identify bottlenecks, failures, and error patterns - as they occur -- **📊 Workflow optimization**: Make informed decisions to improve - configurations and processes -- **🏢 Enterprise governance**: Monitor usage across teams, track costs, ensure - compliance, and integrate with existing monitoring infrastructure +Whether you are debugging a complex tool interaction locally or monitoring +enterprise-wide usage in the cloud, Gemini CLI's observability system provides +the actionable intelligence needed to move from "black box" AI to predictable, +high-performance systems. ## OpenTelemetry integration -Built on **[OpenTelemetry]** — the vendor-neutral, industry-standard -observability framework — Gemini CLI's observability system provides: +Gemini CLI integrates with **[OpenTelemetry]**, a vendor-neutral, +industry-standard observability framework. -- **Universal compatibility**: Export to any OpenTelemetry backend (Google - Cloud, Jaeger, Prometheus, Datadog, etc.) -- **Standardized data**: Use consistent formats and collection methods across - your toolchain -- **Future-proof integration**: Connect with existing and future observability - infrastructure -- **No vendor lock-in**: Switch between backends without changing your - instrumentation +The observability system provides: + +- Universal compatibility: Export to any OpenTelemetry backend (Google Cloud, + Jaeger, Prometheus, Datadog, etc.). +- Standardized data: Use consistent formats and collection methods across your + toolchain. +- Future-proof integration: Connect with existing and future observability + infrastructure. +- No vendor lock-in: Switch between backends without changing your + instrumentation. [OpenTelemetry]: https://opentelemetry.io/ ## Configuration -All telemetry behavior is controlled through your `.gemini/settings.json` file. -Environment variables can be used to override the settings in the file. +You control telemetry behavior through the `.gemini/settings.json` file. +Environment variables can override these settings. | Setting | Environment Variable | Description | Values | Default | | -------------- | -------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- | @@ -87,143 +45,149 @@ Environment variables can be used to override the settings in the file. | `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` | | `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` | | `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` | +| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - | -**Note on boolean environment variables:** For the boolean settings (`enabled`, -`logPrompts`, `useCollector`), setting the corresponding environment variable to -`true` or `1` will enable the feature. Any other value will disable it. +**Note on boolean environment variables:** For boolean settings like `enabled`, +setting the environment variable to `true` or `1` enables the feature. -For detailed information about all configuration options, see the +For detailed configuration information, see the [Configuration guide](../reference/configuration.md). ## Google Cloud telemetry +You can export telemetry data directly to Google Cloud Trace, Cloud Monitoring, +and Cloud Logging. + ### Prerequisites -Before using either method below, complete these steps: +You must complete several setup steps before enabling Google Cloud telemetry. -1. Set your Google Cloud project ID: - - For telemetry in a separate project from inference: - ```bash - export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" - ``` - - For telemetry in the same project as inference: - ```bash - export GOOGLE_CLOUD_PROJECT="your-project-id" - ``` +1. Set your Google Cloud project ID: + - To send telemetry to a separate project: -2. Authenticate with Google Cloud: - - If using a user account: - ```bash - gcloud auth application-default login - ``` - - If using a service account: - ```bash - export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" - ``` -3. Make sure your account or service account has these IAM roles: - - Cloud Trace Agent - - Monitoring Metric Writer - - Logs Writer + **macOS/Linux** -4. Enable the required Google Cloud APIs (if not already enabled): - ```bash - gcloud services enable \ - cloudtrace.googleapis.com \ - monitoring.googleapis.com \ - logging.googleapis.com \ - --project="$OTLP_GOOGLE_CLOUD_PROJECT" - ``` + ```bash + export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" + ``` -### Authenticating with CLI Credentials + **Windows (PowerShell)** -By default, the telemetry collector for Google Cloud uses Application Default -Credentials (ADC). However, you can configure it to use the same OAuth -credentials that you use to log in to the Gemini CLI. This is useful in -environments where you don't have ADC set up. + ```powershell + $env:OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" + ``` -To enable this, set the `useCliAuth` property in your `telemetry` settings to -`true`: + - To send telemetry to the same project as inference: -```json -{ - "telemetry": { - "enabled": true, - "target": "gcp", - "useCliAuth": true - } -} -``` + **macOS/Linux** -**Important:** + ```bash + export GOOGLE_CLOUD_PROJECT="your-project-id" + ``` -- This setting requires the use of **Direct Export** (in-process exporters). -- It **cannot** be used with `useCollector: true`. If you enable both, telemetry - will be disabled and an error will be logged. -- The CLI will automatically use your credentials to authenticate with Google - Cloud Trace, Metrics, and Logging APIs. + **Windows (PowerShell)** -### Direct export (recommended) + ```powershell + $env:GOOGLE_CLOUD_PROJECT="your-project-id" + ``` -Sends telemetry directly to Google Cloud services. No collector needed. +2. Authenticate with Google Cloud using one of these methods: + - **Method A: Application Default Credentials (ADC)**: Use this method for + service accounts or standard `gcloud` authentication. + - For user accounts: + ```bash + gcloud auth application-default login + ``` + - For service accounts: -1. Enable telemetry in your `.gemini/settings.json`: - ```json - { - "telemetry": { - "enabled": true, - "target": "gcp" - } - } - ``` -2. Run Gemini CLI and send prompts. -3. View logs and metrics: - - Open the Google Cloud Console in your browser after sending prompts: - - Logs: https://console.cloud.google.com/logs/ - - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer - - Traces: https://console.cloud.google.com/traces/list + **macOS/Linux** -### Collector-based export (advanced) + ```bash + export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" + ``` -For custom processing, filtering, or routing, use an OpenTelemetry collector to -forward data to Google Cloud. + **Windows (PowerShell)** -1. Configure your `.gemini/settings.json`: - ```json - { - "telemetry": { - "enabled": true, - "target": "gcp", - "useCollector": true - } - } - ``` -2. Run the automation script: - ```bash - npm run telemetry -- --target=gcp - ``` - This will: - - Start a local OTEL collector that forwards to Google Cloud - - Configure your workspace - - Provide links to view traces, metrics, and logs in Google Cloud Console - - Save collector logs to `~/.gemini/tmp//otel/collector-gcp.log` - - Stop collector on exit (e.g. `Ctrl+C`) -3. Run Gemini CLI and send prompts. -4. View logs and metrics: - - Open the Google Cloud Console in your browser after sending prompts: - - Logs: https://console.cloud.google.com/logs/ - - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer - - Traces: https://console.cloud.google.com/traces/list - - Open `~/.gemini/tmp//otel/collector-gcp.log` to view local - collector logs. + ```powershell + $env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\service-account.json" + ``` + * **Method B: CLI Auth** (Direct export only): Simplest method for local + users. Gemini CLI uses the same OAuth credentials you used for login. To + enable this, set `useCliAuth: true` in your `.gemini/settings.json`: -### Monitoring Dashboards + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp", + "useCliAuth": true + } + } + ``` + + +> [!NOTE] +> This setting requires **Direct export** (in-process exporters) +> and cannot be used when `useCollector` is `true`. If both are enabled, +> telemetry will be disabled. + +3. Ensure your account or service account has these IAM roles: + - Cloud Trace Agent + - Monitoring Metric Writer + - Logs Writer + +4. Enable the required Google Cloud APIs: + ```bash + gcloud services enable \ + cloudtrace.googleapis.com \ + monitoring.googleapis.com \ + logging.googleapis.com \ + --project="$OTLP_GOOGLE_CLOUD_PROJECT" + ``` + +### Direct export + +We recommend using direct export to send telemetry directly to Google Cloud +services. + +1. Enable telemetry in `.gemini/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp" + } + } + ``` +2. Run Gemini CLI and send prompts. +3. View logs, metrics, and traces in the Google Cloud Console. See + [View Google Cloud telemetry](#view-google-cloud-telemetry) for details. + +### View Google Cloud telemetry + +After you enable telemetry and run Gemini CLI, you can view your data in the +Google Cloud Console. + +- **Logs:** [Logs Explorer](https://console.cloud.google.com/logs/) +- **Metrics:** + [Metrics Explorer](https://console.cloud.google.com/monitoring/metrics-explorer) +- **Traces:** [Trace Explorer](https://console.cloud.google.com/traces/list) + +For detailed information on how to use these tools, see the following official +Google Cloud documentation: + +- [View and analyze logs with Logs Explorer](https://cloud.google.com/logging/docs/view/logs-explorer-interface) +- [Create charts with Metrics Explorer](https://cloud.google.com/monitoring/charts/metrics-explorer) +- [Find and explore traces](https://cloud.google.com/trace/docs/finding-traces) + +#### Monitoring dashboards Gemini CLI provides a pre-configured [Google Cloud Monitoring](https://cloud.google.com/monitoring) dashboard to visualize your telemetry. -This dashboard can be found under **Google Cloud Monitoring Dashboard -Templates** as "**Gemini CLI Monitoring**". +Find this dashboard under **Google Cloud Monitoring Dashboard Templates** as +"**Gemini CLI Monitoring**". ![Gemini CLI Monitoring Dashboard Overview](/docs/assets/monitoring-dashboard-overview.png) @@ -231,635 +195,1071 @@ Templates** as "**Gemini CLI Monitoring**". ![Gemini CLI Monitoring Dashboard Logs](/docs/assets/monitoring-dashboard-logs.png) -To learn more, check out this blog post: -[Instant insights: Gemini CLI’s new pre-configured monitoring dashboards](https://cloud.google.com/blog/topics/developers-practitioners/instant-insights-gemini-clis-new-pre-configured-monitoring-dashboards/). +To learn more, see +[Instant insights: Gemini CLI’s pre-configured monitoring dashboards](https://cloud.google.com/blog/topics/developers-practitioners/instant-insights-gemini-clis-new-pre-configured-monitoring-dashboards/). ## Local telemetry -For local development and debugging, you can capture telemetry data locally: +You can capture telemetry data locally for development and debugging. We +recommend using file-based output for local development. -### File-based output (recommended) +1. Enable telemetry in `.gemini/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "local", + "outfile": ".gemini/telemetry.log" + } + } + ``` +2. Run Gemini CLI and send prompts. +3. View logs and metrics in `.gemini/telemetry.log`. -1. Enable telemetry in your `.gemini/settings.json`: - ```json - { - "telemetry": { - "enabled": true, - "target": "local", - "otlpEndpoint": "", - "outfile": ".gemini/telemetry.log" - } - } - ``` -2. Run Gemini CLI and send prompts. -3. View logs and metrics in the specified file (e.g., `.gemini/telemetry.log`). +For advanced local telemetry setups (such as Jaeger or Genkit), see the +[Local development guide](../local-development.md#viewing-traces). -### Collector-based export (advanced) +## Client identification -1. Run the automation script: - ```bash - npm run telemetry -- --target=local - ``` - This will: - - Download and start Jaeger and OTEL collector - - Configure your workspace for local telemetry - - Provide a Jaeger UI at http://localhost:16686 - - Save logs/metrics to `~/.gemini/tmp//otel/collector.log` - - Stop collector on exit (e.g. `Ctrl+C`) -2. Run Gemini CLI and send prompts. -3. View traces at http://localhost:16686 and logs/metrics in the collector log - file. +Gemini CLI includes identifiers in its `User-Agent` header to help you +differentiate and report on API traffic from different environments (for +example, identifying calls from Gemini Code Assist versus a standard terminal). -## Logs and metrics +### Automatic identification -The following section describes the structure of logs and metrics generated for +Most integrated environments are identified automatically without additional +configuration. The identifier is included as a prefix to the `User-Agent` and as +a "surface" tag in the parenthetical metadata. + +| Environment | User-Agent Prefix | Surface Tag | +| :---------------------------------- | :--------------------------- | :---------- | +| **Gemini Code Assist (Agent Mode)** | `GeminiCLI-a2a-server` | `vscode` | +| **Zed (via ACP)** | `GeminiCLI-acp-zed` | `zed` | +| **XCode (via ACP)** | `GeminiCLI-acp-xcode` | `xcode` | +| **IntelliJ IDEA (via ACP)** | `GeminiCLI-acp-intellijidea` | `jetbrains` | +| **Standard Terminal** | `GeminiCLI` | `terminal` | + +**Example User-Agent:** +`GeminiCLI-a2a-server/0.34.0/gemini-pro (linux; x64; vscode)` + +### Custom identification + +You can provide a custom identifier for your own scripts or automation by +setting the `GEMINI_CLI_SURFACE` environment variable. This is useful for +tracking specific internal tools or distribution channels in your GCP logs. + +**macOS/Linux** + +```bash +export GEMINI_CLI_SURFACE="my-custom-tool" +``` + +**Windows (PowerShell)** + +```powershell +$env:GEMINI_CLI_SURFACE="my-custom-tool" +``` + +When set, the value appears at the end of the `User-Agent` parenthetical: +`GeminiCLI/0.34.0/gemini-pro (linux; x64; my-custom-tool)` + +## Logs, metrics, and traces + +This section describes the structure of logs, metrics, and traces generated by Gemini CLI. -The `session.id`, `installation.id`, `active_approval_mode`, and `user.email` -(available only when authenticated with a Google account) are included as common -attributes on all logs and metrics. +Gemini CLI includes `session.id`, `installation.id`, `active_approval_mode`, and +`user.email` (when authenticated) as common attributes on all data. ### Logs -Logs are timestamped records of specific events. The following events are logged -for Gemini CLI, grouped by category. +Logs provide timestamped records of specific events. Gemini CLI logs events +across several categories. #### Sessions -Captures startup configuration and user prompt submissions. +Session logs capture startup configuration and prompt submissions. -- `gemini_cli.config`: Emitted once at startup with the CLI configuration. - - **Attributes**: - - `model` (string) - - `embedding_model` (string) - - `sandbox_enabled` (boolean) - - `core_tools_enabled` (string) - - `approval_mode` (string) - - `api_key_enabled` (boolean) - - `vertex_ai_enabled` (boolean) - - `log_user_prompts_enabled` (boolean) - - `file_filtering_respect_git_ignore` (boolean) - - `debug_mode` (boolean) - - `mcp_servers` (string) - - `mcp_servers_count` (int) - - `extensions` (string) - - `extension_ids` (string) - - `extension_count` (int) - - `mcp_tools` (string, if applicable) - - `mcp_tools_count` (int, if applicable) - - `output_format` ("text", "json", or "stream-json") +##### `gemini_cli.config` -- `gemini_cli.user_prompt`: Emitted when a user submits a prompt. - - **Attributes**: - - `prompt_length` (int) - - `prompt_id` (string) - - `prompt` (string; excluded if `telemetry.logPrompts` is `false`) - - `auth_type` (string) +Emitted at startup with the CLI configuration. -#### Approval Mode +
+Attributes -Tracks changes and duration of approval modes. +- `model` (string) +- `embedding_model` (string) +- `sandbox_enabled` (boolean) +- `core_tools_enabled` (string) +- `approval_mode` (string) +- `api_key_enabled` (boolean) +- `vertex_ai_enabled` (boolean) +- `log_user_prompts_enabled` (boolean) +- `file_filtering_respect_git_ignore` (boolean) +- `debug_mode` (boolean) +- `mcp_servers` (string) +- `mcp_servers_count` (int) +- `mcp_tools` (string) +- `mcp_tools_count` (int) +- `output_format` (string) +- `extensions` (string) +- `extension_ids` (string) +- `extensions_count` (int) +- `auth_type` (string) +- `worktree_active` (boolean) +- `github_workflow_name` (string, optional) +- `github_repository_hash` (string, optional) +- `github_event_name` (string, optional) +- `github_pr_number` (string, optional) +- `github_issue_number` (string, optional) +- `github_custom_tracking_id` (string, optional) + +
+ +##### `gemini_cli.user_prompt` + +Emitted when you submit a prompt. + +
+Attributes + +- `prompt_length` (int) +- `prompt_id` (string) +- `prompt` (string; excluded if `telemetry.logPrompts` is `false`) +- `auth_type` (string) + +
+ +#### Approval mode + +These logs track changes to and usage of different approval modes. ##### Lifecycle -- `approval_mode_switch`: Approval mode was changed. - - **Attributes**: - - `from_mode` (string) - - `to_mode` (string) +##### `approval_mode_switch` -- `approval_mode_duration`: Duration spent in an approval mode. - - **Attributes**: - - `mode` (string) - - `duration_ms` (int) +Logs when you change the approval mode. + +
+Attributes + +- `from_mode` (string) +- `to_mode` (string) + +
+ +##### `approval_mode_duration` + +Records time spent in an approval mode. + +
+Attributes + +- `mode` (string) +- `duration_ms` (int) + +
##### Execution -These events track the execution of an approval mode, such as Plan Mode. +##### `plan_execution` -- `plan_execution`: A plan was executed and the session switched from plan mode - to active execution. - - **Attributes**: - - `approval_mode` (string) +Logs when you execute a plan and switch from plan mode to active execution. + +
+Attributes + +- `approval_mode` (string) + +
#### Tools -Captures tool executions, output truncation, and Edit behavior. +Tool logs capture executions, truncation, and edit behavior. -- `gemini_cli.tool_call`: Emitted for each tool (function) call. - - **Attributes**: - - `function_name` - - `function_args` - - `duration_ms` - - `success` (boolean) - - `decision` ("accept", "reject", "auto_accept", or "modify", if applicable) - - `error` (if applicable) - - `error_type` (if applicable) - - `prompt_id` (string) - - `tool_type` ("native" or "mcp") - - `mcp_server_name` (string, if applicable) - - `extension_name` (string, if applicable) - - `extension_id` (string, if applicable) - - `content_length` (int, if applicable) - - `metadata` (if applicable), which includes for the `AskUser` tool: - - `ask_user` (object): - - `question_types` (array of strings) - - `ask_user_dismissed` (boolean) - - `ask_user_empty_submission` (boolean) - - `ask_user_answer_count` (number) - - `diffStat` (if applicable), which includes: - - `model_added_lines` (number) - - `model_removed_lines` (number) - - `model_added_chars` (number) - - `model_removed_chars` (number) - - `user_added_lines` (number) - - `user_removed_lines` (number) - - `user_added_chars` (number) - - `user_removed_chars` (number) +##### `gemini_cli.tool_call` -- `gemini_cli.tool_output_truncated`: Output of a tool call was truncated. - - **Attributes**: - - `tool_name` (string) - - `original_content_length` (int) - - `truncated_content_length` (int) - - `threshold` (int) - - `lines` (int) - - `prompt_id` (string) +Emitted for each tool (function) call. -- `gemini_cli.edit_strategy`: Edit strategy chosen. - - **Attributes**: - - `strategy` (string) +
+Attributes -- `gemini_cli.edit_correction`: Edit correction result. - - **Attributes**: - - `correction` ("success" | "failure") +- `function_name` (string) +- `function_args` (string) +- `duration_ms` (int) +- `success` (boolean) +- `decision` (string: "accept", "reject", "auto_accept", or "modify") +- `error` (string, optional) +- `error_type` (string, optional) +- `prompt_id` (string) +- `tool_type` (string: "native" or "mcp") +- `mcp_server_name` (string, optional) +- `extension_name` (string, optional) +- `extension_id` (string, optional) +- `content_length` (int, optional) +- `start_time` (number, optional) +- `end_time` (number, optional) +- `metadata` (object, optional), which may include: + - `model_added_lines` (number) + - `model_removed_lines` (number) + - `user_added_lines` (number) + - `user_removed_lines` (number) + - `ask_user` (object) -- `gen_ai.client.inference.operation.details`: This event provides detailed - information about the GenAI operation, aligned with [OpenTelemetry GenAI - semantic conventions for events]. - - **Attributes**: - - `gen_ai.request.model` (string) - - `gen_ai.provider.name` (string) - - `gen_ai.operation.name` (string) - - `gen_ai.input.messages` (json string) - - `gen_ai.output.messages` (json string) - - `gen_ai.response.finish_reasons` (array of strings) - - `gen_ai.usage.input_tokens` (int) - - `gen_ai.usage.output_tokens` (int) - - `gen_ai.request.temperature` (float) - - `gen_ai.request.top_p` (float) - - `gen_ai.request.top_k` (int) - - `gen_ai.request.max_tokens` (int) - - `gen_ai.system_instructions` (json string) - - `server.address` (string) - - `server.port` (int) +
+ +##### `gemini_cli.tool_output_truncated` + +Logs when tool output is truncated. + +
+Attributes + +- `tool_name` (string) +- `original_content_length` (int) +- `truncated_content_length` (int) +- `threshold` (int) +- `lines` (int) +- `prompt_id` (string) + +
+ +##### `gemini_cli.edit_strategy` + +Records the chosen edit strategy. + +
+Attributes + +- `strategy` (string) + +
+ +##### `gemini_cli.edit_correction` + +Records the result of an edit correction. + +
+Attributes + +- `correction` (string: "success" or "failure") + +
+ +##### `gen_ai.client.inference.operation.details` + +Provides detailed GenAI operation data aligned with OpenTelemetry conventions. + +
+Attributes + +- `gen_ai.request.model` (string) +- `gen_ai.provider.name` (string) +- `gen_ai.operation.name` (string) +- `gen_ai.input.messages` (json string) +- `gen_ai.output.messages` (json string) +- `gen_ai.response.finish_reasons` (array of strings) +- `gen_ai.usage.input_tokens` (int) +- `gen_ai.usage.output_tokens` (int) +- `gen_ai.request.temperature` (float) +- `gen_ai.request.top_p` (float) +- `gen_ai.request.top_k` (int) +- `gen_ai.request.max_tokens` (int) +- `gen_ai.system_instructions` (json string) +- `server.address` (string) +- `server.port` (int) + +
#### Files -Tracks file operations performed by tools. +File logs track operations performed by tools. -- `gemini_cli.file_operation`: Emitted for each file operation. - - **Attributes**: - - `tool_name` (string) - - `operation` ("create" | "read" | "update") - - `lines` (int, optional) - - `mimetype` (string, optional) - - `extension` (string, optional) - - `programming_language` (string, optional) +##### `gemini_cli.file_operation` + +Emitted for each file creation, read, or update. + +
+Attributes + +- `tool_name` (string) +- `operation` (string: "create", "read", or "update") +- `lines` (int, optional) +- `mimetype` (string, optional) +- `extension` (string, optional) +- `programming_language` (string, optional) + +
#### API -Captures Gemini API requests, responses, and errors. +API logs capture requests, responses, and errors from Gemini API. -- `gemini_cli.api_request`: Request sent to Gemini API. - - **Attributes**: - - `model` (string) - - `prompt_id` (string) - - `request_text` (string, optional) +##### `gemini_cli.api_request` -- `gemini_cli.api_response`: Response received from Gemini API. - - **Attributes**: - - `model` (string) - - `status_code` (int|string) - - `duration_ms` (int) - - `input_token_count` (int) - - `output_token_count` (int) - - `cached_content_token_count` (int) - - `thoughts_token_count` (int) - - `tool_token_count` (int) - - `total_token_count` (int) - - `response_text` (string, optional) - - `prompt_id` (string) - - `auth_type` (string) - - `finish_reasons` (array of strings) +Request sent to Gemini API. -- `gemini_cli.api_error`: API request failed. - - **Attributes**: - - `model` (string) - - `error` (string) - - `error_type` (string) - - `status_code` (int|string) - - `duration_ms` (int) - - `prompt_id` (string) - - `auth_type` (string) +
+Attributes -- `gemini_cli.malformed_json_response`: `generateJson` response could not be - parsed. - - **Attributes**: - - `model` (string) +- `model` (string) +- `prompt_id` (string) +- `role` (string: "user", "model", or "system") +- `request_text` (string, optional) + +
+ +##### `gemini_cli.api_response` + +Response received from Gemini API. + +
+Attributes + +- `model` (string) +- `status_code` (int or string) +- `duration_ms` (int) +- `input_token_count` (int) +- `output_token_count` (int) +- `cached_content_token_count` (int) +- `thoughts_token_count` (int) +- `tool_token_count` (int) +- `total_token_count` (int) +- `prompt_id` (string) +- `auth_type` (string) +- `finish_reasons` (array of strings) +- `response_text` (string, optional) + +
+ +##### `gemini_cli.api_error` + +Logs when an API request fails. + +
+Attributes + +- `error.message` (string) +- `model_name` (string) +- `duration` (int) +- `prompt_id` (string) +- `auth_type` (string) +- `error_type` (string, optional) +- `status_code` (int or string, optional) +- `role` (string, optional) + +
+ +##### `gemini_cli.malformed_json_response` + +Logs when a JSON response cannot be parsed. + +
+Attributes + +- `model` (string) + +
#### Model routing -- `gemini_cli.slash_command`: A slash command was executed. - - **Attributes**: - - `command` (string) - - `subcommand` (string, optional) - - `status` ("success" | "error") +These logs track how Gemini CLI selects and routes requests to models. -- `gemini_cli.slash_command.model`: Model was selected via slash command. - - **Attributes**: - - `model_name` (string) +##### `gemini_cli.slash_command` -- `gemini_cli.model_routing`: Model router made a decision. - - **Attributes**: - - `decision_model` (string) - - `decision_source` (string) - - `routing_latency_ms` (int) - - `reasoning` (string, optional) - - `failed` (boolean) - - `error_message` (string, optional) - - `approval_mode` (string) +Logs slash command execution. + +
+Attributes + +- `command` (string) +- `subcommand` (string, optional) +- `status` (string: "success" or "error") + +
+ +##### `gemini_cli.slash_command.model` + +Logs model selection via slash command. + +
+Attributes + +- `model_name` (string) + +
+ +##### `gemini_cli.model_routing` + +Records model router decisions and reasoning. + +
+Attributes + +- `decision_model` (string) +- `decision_source` (string) +- `routing_latency_ms` (int) +- `reasoning` (string, optional) +- `failed` (boolean) +- `error_message` (string, optional) +- `approval_mode` (string) + +
#### Chat and streaming -- `gemini_cli.chat_compression`: Chat context was compressed. - - **Attributes**: - - `tokens_before` (int) - - `tokens_after` (int) +These logs track chat context compression and streaming chunk errors. -- `gemini_cli.chat.invalid_chunk`: Invalid chunk received from a stream. - - **Attributes**: - - `error.message` (string, optional) +##### `gemini_cli.chat_compression` -- `gemini_cli.chat.content_retry`: Retry triggered due to a content error. - - **Attributes**: - - `attempt_number` (int) - - `error_type` (string) - - `retry_delay_ms` (int) - - `model` (string) +Logs chat context compression events. -- `gemini_cli.chat.content_retry_failure`: All content retries failed. - - **Attributes**: - - `total_attempts` (int) - - `final_error_type` (string) - - `total_duration_ms` (int, optional) - - `model` (string) +
+Attributes -- `gemini_cli.conversation_finished`: Conversation session ended. - - **Attributes**: - - `approvalMode` (string) - - `turnCount` (int) +- `tokens_before` (int) +- `tokens_after` (int) -- `gemini_cli.next_speaker_check`: Next speaker determination. - - **Attributes**: - - `prompt_id` (string) - - `finish_reason` (string) - - `result` (string) +
+ +##### `gemini_cli.chat.invalid_chunk` + +Logs invalid chunks received in a stream. + +
+Attributes + +- `error_message` (string, optional) + +
+ +##### `gemini_cli.chat.content_retry` + +Logs retries due to content errors. + +
+Attributes + +- `attempt_number` (int) +- `error_type` (string) +- `retry_delay_ms` (int) +- `model` (string) + +
+ +##### `gemini_cli.chat.content_retry_failure` + +Logs when all content retries fail. + +
+Attributes + +- `total_attempts` (int) +- `final_error_type` (string) +- `total_duration_ms` (int, optional) +- `model` (string) + +
+ +##### `gemini_cli.conversation_finished` + +Logs when a conversation session ends. + +
+Attributes + +- `approvalMode` (string) +- `turnCount` (int) + +
#### Resilience -Records fallback mechanisms for models and network operations. +Resilience logs record fallback mechanisms and recovery attempts. -- `gemini_cli.flash_fallback`: Switched to a flash model as fallback. - - **Attributes**: - - `auth_type` (string) +##### `gemini_cli.flash_fallback` -- `gemini_cli.ripgrep_fallback`: Switched to grep as fallback for file search. - - **Attributes**: - - `error` (string, optional) +Logs switch to a flash model fallback. -- `gemini_cli.web_fetch_fallback_attempt`: Attempted web-fetch fallback. - - **Attributes**: - - `reason` ("private_ip" | "primary_failed") +
+Attributes + +- `auth_type` (string) + +
+ +##### `gemini_cli.ripgrep_fallback` + +Logs fallback to standard grep. + +
+Attributes + +- `error` (string, optional) + +
+ +##### `gemini_cli.web_fetch_fallback_attempt` + +Logs web-fetch fallback attempts. + +
+Attributes + +- `reason` (string: "private_ip" or "primary_failed") + +
+ +##### `gemini_cli.agent.recovery_attempt` + +Logs attempts to recover from agent errors. + +
+Attributes + +- `agent_name` (string) +- `attempt_number` (int) +- `success` (boolean) +- `error_type` (string, optional) + +
#### Extensions -Tracks extension lifecycle and settings changes. +Extension logs track lifecycle events and settings changes. -- `gemini_cli.extension_install`: An extension was installed. - - **Attributes**: - - `extension_name` (string) - - `extension_version` (string) - - `extension_source` (string) - - `status` (string) +##### `gemini_cli.extension_install` -- `gemini_cli.extension_uninstall`: An extension was uninstalled. - - **Attributes**: - - `extension_name` (string) - - `status` (string) +Logs when you install an extension. -- `gemini_cli.extension_enable`: An extension was enabled. - - **Attributes**: - - `extension_name` (string) - - `setting_scope` (string) +
+Attributes -- `gemini_cli.extension_disable`: An extension was disabled. - - **Attributes**: - - `extension_name` (string) - - `setting_scope` (string) +- `extension_name` (string) +- `extension_version` (string) +- `extension_source` (string) +- `status` (string) -- `gemini_cli.extension_update`: An extension was updated. - - **Attributes**: - - `extension_name` (string) - - `extension_version` (string) - - `extension_previous_version` (string) - - `extension_source` (string) - - `status` (string) +
+ +##### `gemini_cli.extension_uninstall` + +Logs when you uninstall an extension. + +
+Attributes + +- `extension_name` (string) +- `status` (string) + +
+ +##### `gemini_cli.extension_enable` + +Logs when you enable an extension. + +
+Attributes + +- `extension_name` (string) +- `setting_scope` (string) + +
+ +##### `gemini_cli.extension_disable` + +Logs when you disable an extension. + +
+Attributes + +- `extension_name` (string) +- `setting_scope` (string) + +
#### Agent runs -- `gemini_cli.agent.start`: Agent run started. - - **Attributes**: - - `agent_id` (string) - - `agent_name` (string) +Agent logs track the lifecycle of agent executions. -- `gemini_cli.agent.finish`: Agent run finished. - - **Attributes**: - - `agent_id` (string) - - `agent_name` (string) - - `duration_ms` (int) - - `turn_count` (int) - - `terminate_reason` (string) +##### `gemini_cli.agent.start` + +Logs when an agent run begins. + +
+Attributes + +- `agent_id` (string) +- `agent_name` (string) + +
+ +##### `gemini_cli.agent.finish` + +Logs when an agent run completes. + +
+Attributes + +- `agent_id` (string) +- `agent_name` (string) +- `duration_ms` (int) +- `turn_count` (int) +- `terminate_reason` (string) + +
#### IDE -Captures IDE connectivity and conversation lifecycle events. +IDE logs capture connectivity events for the IDE companion. -- `gemini_cli.ide_connection`: IDE companion connection. - - **Attributes**: - - `connection_type` (string) +##### `gemini_cli.ide_connection` + +Logs IDE companion connections. + +
+Attributes + +- `connection_type` (string) + +
#### UI -Tracks terminal rendering issues and related signals. +UI logs track terminal rendering issues. -- `kitty_sequence_overflow`: Terminal kitty control sequence overflow. - - **Attributes**: - - `sequence_length` (int) - - `truncated_sequence` (string) +##### `kitty_sequence_overflow` + +Logs terminal control sequence overflows. + +
+Attributes + +- `sequence_length` (int) +- `truncated_sequence` (string) + +
+ +#### Miscellaneous + +##### `gemini_cli.rewind` + +Logs when the conversation state is rewound. + +
+Attributes + +- `outcome` (string) + +
+ +##### `gemini_cli.conseca.verdict` + +Logs security verdicts from ConSeca. + +
+Attributes + +- `verdict` (string) +- `decision` (string: "accept", "reject", or "modify") +- `reason` (string, optional) +- `tool_name` (string, optional) + +
+ +##### `gemini_cli.hook_call` + +Logs execution of lifecycle hooks. + +
+Attributes + +- `hook_name` (string) +- `hook_type` (string) +- `duration_ms` (int) +- `success` (boolean) + +
+ +##### `gemini_cli.tool_output_masking` + +Logs when tool output is masked for privacy. + +
+Attributes + +- `tokens_before` (int) +- `tokens_after` (int) +- `masked_count` (int) +- `total_prunable_tokens` (int) + +
+ +##### `gemini_cli.keychain.availability` + +Logs keychain availability checks. + +
+Attributes + +- `available` (boolean) + +##### `gemini_cli.startup_stats` + +Logs detailed startup performance statistics. + +
+Attributes + +- `phases` (json array of startup phases) +- `os_platform` (string) +- `os_release` (string) +- `is_docker` (boolean) + +
+ +
### Metrics -Metrics are numerical measurements of behavior over time. +Metrics provide numerical measurements of behavior over time. -#### Custom +#### Custom metrics + +Gemini CLI exports several custom metrics. ##### Sessions -Counts CLI sessions at startup. +##### `gemini_cli.session.count` -- `gemini_cli.session.count` (Counter, Int): Incremented once per CLI startup. +Incremented once per CLI startup. + +##### Onboarding + +Tracks onboarding flow from authentication to the user + +- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the + authentication flow begins. + +- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user +onboarding flow completes successfully. +
+Attributes (Success) + +- `user_tier` (string) ##### Tools -Measures tool usage and latency. +##### `gemini_cli.tool.call.count` -- `gemini_cli.tool.call.count` (Counter, Int): Counts tool calls. - - **Attributes**: - - `function_name` - - `success` (boolean) - - `decision` (string: "accept", "reject", "modify", or "auto_accept", if - applicable) - - `tool_type` (string: "mcp" or "native", if applicable) +Counts tool calls. -- `gemini_cli.tool.call.latency` (Histogram, ms): Measures tool call latency. - - **Attributes**: - - `function_name` +
+Attributes + +- `function_name` (string) +- `success` (boolean) +- `decision` (string: "accept", "reject", "modify", or "auto_accept") +- `tool_type` (string: "mcp" or "native") + +
+ +##### `gemini_cli.tool.call.latency` + +Measures tool call latency (in ms). + +
+Attributes + +- `function_name` (string) + +
##### API -Tracks API request volume and latency. +##### `gemini_cli.api.request.count` -- `gemini_cli.api.request.count` (Counter, Int): Counts all API requests. - - **Attributes**: - - `model` - - `status_code` - - `error_type` (if applicable) +Counts all API requests. -- `gemini_cli.api.request.latency` (Histogram, ms): Measures API request - latency. - - **Attributes**: - - `model` - - Note: Overlaps with `gen_ai.client.operation.duration` (GenAI conventions). +
+Attributes + +- `model` (string) +- `status_code` (int or string) +- `error_type` (string, optional) + +
+ +##### `gemini_cli.api.request.latency` + +Measures API request latency (in ms). + +
+Attributes + +- `model` (string) + +
##### Token usage -Tracks tokens used by model and type. +##### `gemini_cli.token.usage` -- `gemini_cli.token.usage` (Counter, Int): Counts tokens used. - - **Attributes**: - - `model` - - `type` ("input", "output", "thought", "cache", or "tool") - - Note: Overlaps with `gen_ai.client.token.usage` for `input`/`output`. +Counts input, output, thought, cache, and tool tokens. + +
+Attributes + +- `model` (string) +- `type` (string: "input", "output", "thought", "cache", or "tool") + +
##### Files -Counts file operations with basic context. +##### `gemini_cli.file.operation.count` -- `gemini_cli.file.operation.count` (Counter, Int): Counts file operations. - - **Attributes**: - - `operation` ("create", "read", "update") - - `lines` (Int, optional) - - `mimetype` (string, optional) - - `extension` (string, optional) - - `programming_language` (string, optional) +Counts file operations. -- `gemini_cli.lines.changed` (Counter, Int): Number of lines changed (from file - diffs). - - **Attributes**: - - `function_name` - - `type` ("added" or "removed") +
+Attributes + +- `operation` (string: "create", "read", or "update") +- `lines` (int, optional) +- `mimetype` (string, optional) +- `extension` (string, optional) +- `programming_language` (string, optional) + +
+ +##### `gemini_cli.lines.changed` + +Counts added or removed lines. + +
+Attributes + +- `function_name` (string, optional) +- `type` (string: "added" or "removed") + +
##### Chat and streaming -Resilience counters for compression, invalid chunks, and retries. +##### `gemini_cli.chat_compression` -- `gemini_cli.chat_compression` (Counter, Int): Counts chat compression - operations. - - **Attributes**: - - `tokens_before` (Int) - - `tokens_after` (Int) +Counts compression operations. -- `gemini_cli.chat.invalid_chunk.count` (Counter, Int): Counts invalid chunks - from streams. +
+Attributes -- `gemini_cli.chat.content_retry.count` (Counter, Int): Counts retries due to - content errors. +- `tokens_before` (int) +- `tokens_after` (int) -- `gemini_cli.chat.content_retry_failure.count` (Counter, Int): Counts requests - where all content retries failed. +
+ +##### `gemini_cli.chat.invalid_chunk.count` + +Counts invalid stream chunks. + +##### `gemini_cli.chat.content_retry.count` + +Counts content error retries. + +##### `gemini_cli.chat.content_retry_failure.count` + +Counts requests where all retries failed. ##### Model routing -Routing latency/failures and slash-command selections. +##### `gemini_cli.slash_command.model.call_count` -- `gemini_cli.slash_command.model.call_count` (Counter, Int): Counts model - selections via slash command. - - **Attributes**: - - `slash_command.model.model_name` (string) +Counts model selections. -- `gemini_cli.model_routing.latency` (Histogram, ms): Model routing decision - latency. - - **Attributes**: - - `routing.decision_model` (string) - - `routing.decision_source` (string) - - `routing.approval_mode` (string) +
+Attributes -- `gemini_cli.model_routing.failure.count` (Counter, Int): Counts model routing - failures. - - **Attributes**: - - `routing.decision_source` (string) - - `routing.error_message` (string) - - `routing.approval_mode` (string) +- `slash_command.model.model_name` (string) + +
+ +##### `gemini_cli.model_routing.latency` + +Measures routing decision latency. + +
+Attributes + +- `routing.decision_model` (string) +- `routing.decision_source` (string) +- `routing.approval_mode` (string) + +
+ +##### `gemini_cli.model_routing.failure.count` + +Counts routing failures. + +
+Attributes + +- `routing.decision_source` (string) +- `routing.error_message` (string) +- `routing.approval_mode` (string) + +
##### Agent runs -Agent lifecycle metrics: runs, durations, and turns. +##### `gemini_cli.agent.run.count` -- `gemini_cli.agent.run.count` (Counter, Int): Counts agent runs. - - **Attributes**: - - `agent_name` (string) - - `terminate_reason` (string) +Counts agent runs. -- `gemini_cli.agent.duration` (Histogram, ms): Agent run durations. - - **Attributes**: - - `agent_name` (string) +
+Attributes -- `gemini_cli.agent.turns` (Histogram, turns): Turns taken per agent run. - - **Attributes**: - - `agent_name` (string) +- `agent_name` (string) +- `terminate_reason` (string) -##### Approval Mode +
-###### Execution +##### `gemini_cli.agent.duration` -These metrics track the adoption and usage of specific approval workflows, such -as Plan Mode. +Measures agent run duration. -- `gemini_cli.plan.execution.count` (Counter, Int): Counts plan executions. - - **Attributes**: - - `approval_mode` (string) +
+Attributes + +- `agent_name` (string) + +
+ +##### `gemini_cli.agent.turns` + +Counts turns per agent run. + +
+Attributes + +- `agent_name` (string) + +
+ +##### Approval mode + +##### `gemini_cli.plan.execution.count` + +Counts plan executions. + +
+Attributes + +- `approval_mode` (string) + +
##### UI -UI stability signals such as flicker count. +##### `gemini_cli.ui.flicker.count` -- `gemini_cli.ui.flicker.count` (Counter, Int): Counts UI frames that flicker - (render taller than terminal). +Counts terminal flicker events. ##### Performance -Optional performance monitoring for startup, CPU/memory, and phase timing. +Gemini CLI provides detailed performance metrics for advanced monitoring. -- `gemini_cli.startup.duration` (Histogram, ms): CLI startup time by phase. - - **Attributes**: - - `phase` (string) - - `details` (map, optional) +##### `gemini_cli.startup.duration` -- `gemini_cli.memory.usage` (Histogram, bytes): Memory usage. - - **Attributes**: - - `memory_type` ("heap_used", "heap_total", "external", "rss") - - `component` (string, optional) +Measures startup time by phase. -- `gemini_cli.cpu.usage` (Histogram, percent): CPU usage percentage. - - **Attributes**: - - `component` (string, optional) +
+Attributes -- `gemini_cli.tool.queue.depth` (Histogram, count): Number of tools in the - execution queue. +- `phase` (string) +- `details` (map, optional) -- `gemini_cli.tool.execution.breakdown` (Histogram, ms): Tool time by phase. - - **Attributes**: - - `function_name` (string) - - `phase` ("validation", "preparation", "execution", "result_processing") +
-- `gemini_cli.api.request.breakdown` (Histogram, ms): API request time by phase. - - **Attributes**: - - `model` (string) - - `phase` ("request_preparation", "network_latency", "response_processing", - "token_processing") +##### `gemini_cli.memory.usage` -- `gemini_cli.token.efficiency` (Histogram, ratio): Token efficiency metrics. - - **Attributes**: - - `model` (string) - - `metric` (string) - - `context` (string, optional) +Measures heap and RSS memory. -- `gemini_cli.performance.score` (Histogram, score): Composite performance - score. - - **Attributes**: - - `category` (string) - - `baseline` (number, optional) +
+Attributes -- `gemini_cli.performance.regression` (Counter, Int): Regression detection - events. - - **Attributes**: - - `metric` (string) - - `severity` ("low", "medium", "high") - - `current_value` (number) - - `baseline_value` (number) +- `memory_type` (string: "heap_used", "heap_total", "external", "rss") +- `component` (string, optional) -- `gemini_cli.performance.regression.percentage_change` (Histogram, percent): - Percent change from baseline when regression detected. - - **Attributes**: - - `metric` (string) - - `severity` ("low", "medium", "high") - - `current_value` (number) - - `baseline_value` (number) +
-- `gemini_cli.performance.baseline.comparison` (Histogram, percent): Comparison - to baseline. - - **Attributes**: - - `metric` (string) - - `category` (string) - - `current_value` (number) - - `baseline_value` (number) +##### `gemini_cli.cpu.usage` + +Measures CPU usage percentage. + +
+Attributes + +- `component` (string, optional) + +
+ +##### `gemini_cli.tool.queue.depth` + +Measures tool execution queue depth. + +##### `gemini_cli.tool.execution.breakdown` + +Breaks down tool time by phase. + +
+Attributes + +- `function_name` (string) +- `phase` (string: "validation", "preparation", "execution", + "result_processing") + +
#### GenAI semantic convention -The following metrics comply with [OpenTelemetry GenAI semantic conventions] for -standardized observability across GenAI applications: +These metrics follow standard [OpenTelemetry GenAI semantic conventions]. -- `gen_ai.client.token.usage` (Histogram, token): Number of input and output - tokens used per operation. - - **Attributes**: - - `gen_ai.operation.name` (string): The operation type (e.g., - "generate_content", "chat") - - `gen_ai.provider.name` (string): The GenAI provider ("gcp.gen_ai" or - "gcp.vertex_ai") - - `gen_ai.token.type` (string): The token type ("input" or "output") - - `gen_ai.request.model` (string, optional): The model name used for the - request - - `gen_ai.response.model` (string, optional): The model name that generated - the response - - `server.address` (string, optional): GenAI server address - - `server.port` (int, optional): GenAI server port - -- `gen_ai.client.operation.duration` (Histogram, s): GenAI operation duration in - seconds. - - **Attributes**: - - `gen_ai.operation.name` (string): The operation type (e.g., - "generate_content", "chat") - - `gen_ai.provider.name` (string): The GenAI provider ("gcp.gen_ai" or - "gcp.vertex_ai") - - `gen_ai.request.model` (string, optional): The model name used for the - request - - `gen_ai.response.model` (string, optional): The model name that generated - the response - - `server.address` (string, optional): GenAI server address - - `server.port` (int, optional): GenAI server port - - `error.type` (string, optional): Error type if the operation failed +- `gen_ai.client.token.usage`: Counts tokens used per operation. +- `gen_ai.client.operation.duration`: Measures operation duration in seconds. [OpenTelemetry GenAI semantic conventions]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-metrics.md -[OpenTelemetry GenAI semantic conventions for events]: - https://github.com/open-telemetry/semantic-conventions/blob/8b4f210f43136e57c1f6f47292eb6d38e3bf30bb/docs/gen-ai/gen-ai-events.md + +### Traces + +Traces provide an "under-the-hood" view of agent and backend operations. Use +traces to debug tool interactions and optimize performance. + +Every trace captures rich metadata via standard span attributes. + +
+Standard span attributes + +- `gen_ai.operation.name`: High-level operation (for example, `tool_call`, + `llm_call`, `user_prompt`, `system_prompt`, `agent_call`, or + `schedule_tool_calls`). +- `gen_ai.agent.name`: Set to `gemini-cli`. +- `gen_ai.agent.description`: The service agent description. +- `gen_ai.input.messages`: Input data or metadata. +- `gen_ai.output.messages`: Output data or results. +- `gen_ai.request.model`: Request model name. +- `gen_ai.response.model`: Response model name. +- `gen_ai.prompt.name`: The prompt name. +- `gen_ai.tool.name`: Executed tool name. +- `gen_ai.tool.call_id`: Unique ID for the tool call. +- `gen_ai.tool.description`: Tool description. +- `gen_ai.tool.definitions`: Tool definitions in JSON format. +- `gen_ai.usage.input_tokens`: Number of input tokens. +- `gen_ai.usage.output_tokens`: Number of output tokens. +- `gen_ai.system_instructions`: System instructions in JSON format. +- `gen_ai.conversation.id`: The CLI session ID. + +
+ +For more details on semantic conventions for events, see the +[OpenTelemetry documentation](https://github.com/open-telemetry/semantic-conventions/blob/8b4f210f43136e57c1f6f47292eb6d38e3bf30bb/docs/gen-ai/gen-ai-events.md). diff --git a/docs/cli/themes.md b/docs/cli/themes.md index 08564a249a..55acc75625 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -16,6 +16,8 @@ using the `/theme` command within Gemini CLI: - `Default` - `Dracula` - `GitHub` + - `Holiday` + - `Shades Of Purple` - `Solarized Dark` - **Light themes:** - `ANSI Light` @@ -34,9 +36,11 @@ using the `/theme` command within Gemini CLI: preview or highlight as you select. 4. Confirm your selection to apply the theme. -**Note:** If a theme is defined in your `settings.json` file (either by name or -by a file path), you must remove the `"theme"` setting from the file before you -can change the theme using the `/theme` command. + +> [!NOTE] +> If a theme is defined in your `settings.json` file (either by name or +> by a file path), you must remove the `"theme"` setting from the file before +> you can change the theme using the `/theme` command. ### Theme persistence @@ -177,15 +181,17 @@ custom theme defined in `settings.json`. } ``` -**Security note:** For your safety, Gemini CLI will only load theme files that -are located within your home directory. If you attempt to load a theme from -outside your home directory, a warning will be displayed and the theme will not -be loaded. This is to prevent loading potentially malicious theme files from -untrusted sources. + +> [!WARNING] +> For your safety, Gemini CLI will only load theme files that +> are located within your home directory. If you attempt to load a theme from +> outside your home directory, a warning will be displayed and the theme will +> not be loaded. This is to prevent loading potentially malicious theme files +> from untrusted sources. ### Example custom theme -Custom theme example +Custom theme example ### Using your custom theme @@ -212,58 +218,66 @@ identify their source, for example: `shades-of-green (green-extension)`. ### ANSI -ANSI theme +ANSI theme -### Atom OneDark +### Atom One -Atom One theme +Atom One theme ### Ayu -Ayu theme +Ayu theme ### Default -Default theme +Default theme ### Dracula -Dracula theme +Dracula theme ### GitHub -GitHub theme +GitHub theme + +### Holiday + +Holiday theme + +### Shades Of Purple + +Shades Of Purple theme ### Solarized Dark -Solarized Dark theme +Solarized Dark theme ## Light themes ### ANSI Light -ANSI Light theme +ANSI Light theme ### Ayu Light -Ayu Light theme +Ayu Light theme ### Default Light -Default Light theme +Default Light theme ### GitHub Light -GitHub Light theme +GitHub Light theme ### Google Code -Google Code theme +Google Code theme ### Solarized Light -Solarized Light theme +Solarized Light theme ### Xcode -Xcode Light theme +Xcode Light theme diff --git a/docs/cli/tutorials/automation.md b/docs/cli/tutorials/automation.md index 11e489aff3..4285cdcf3b 100644 --- a/docs/cli/tutorials/automation.md +++ b/docs/cli/tutorials/automation.md @@ -19,14 +19,15 @@ Headless mode runs Gemini CLI once and exits. It's perfect for: ## How to use headless mode -Run Gemini CLI in headless mode by providing a prompt as a positional argument. -This bypasses the interactive chat interface and prints the response to standard -output (stdout). +Run Gemini CLI in headless mode by providing a prompt with the `-p` (or +`--prompt`) flag. This bypasses the interactive chat interface and prints the +response to standard output (stdout). Positional arguments without the flag +default to interactive mode, unless the input or output is piped or redirected. Run a single command: ```bash -gemini "Write a poem about TypeScript" +gemini -p "Write a poem about TypeScript" ``` ## How to pipe input to Gemini CLI @@ -37,14 +38,22 @@ output. Pipe a file: +**macOS/Linux** + ```bash -cat error.log | gemini "Explain why this failed" +cat error.log | gemini -p "Explain why this failed" +``` + +**Windows (PowerShell)** + +```powershell +Get-Content error.log | gemini -p "Explain why this failed" ``` Pipe a command: ```bash -git diff | gemini "Write a commit message for these changes" +git diff | gemini -p "Write a commit message for these changes" ``` ## Use Gemini CLI output in scripts @@ -57,7 +66,10 @@ results to a file. You have a folder of Python scripts and want to generate a `README.md` for each one. -1. Save the following code as `generate_docs.sh`: +1. Save the following code as `generate_docs.sh` (or `generate_docs.ps1` for + Windows): + + **macOS/Linux (`generate_docs.sh`)** ```bash #!/bin/bash @@ -67,18 +79,39 @@ one. echo "Generating docs for $file..." # Ask Gemini CLI to generate the documentation and print it to stdout - gemini "Generate a Markdown documentation summary for @$file. Print the + gemini -p "Generate a Markdown documentation summary for @$file. Print the result to standard output." > "${file%.py}.md" done ``` + **Windows PowerShell (`generate_docs.ps1`)** + + ```powershell + # Loop through all Python files + Get-ChildItem -Filter *.py | ForEach-Object { + Write-Host "Generating docs for $($_.Name)..." + + $newName = $_.Name -replace '\.py$', '.md' + # Ask Gemini CLI to generate the documentation and print it to stdout + gemini -p "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8 + } + ``` + 2. Make the script executable and run it in your directory: + **macOS/Linux** + ```bash chmod +x generate_docs.sh ./generate_docs.sh ``` + **Windows (PowerShell)** + + ```powershell + .\generate_docs.ps1 + ``` + This creates a corresponding Markdown file for every Python file in the folder. @@ -90,7 +123,10 @@ like `jq`. To get pure JSON data from the model, combine the ### Scenario: Extract and return structured data -1. Save the following script as `generate_json.sh`: +1. Save the following script as `generate_json.sh` (or `generate_json.ps1` for + Windows): + + **macOS/Linux (`generate_json.sh`)** ```bash #!/bin/bash @@ -105,13 +141,35 @@ like `jq`. To get pure JSON data from the model, combine the gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | jq -r '.response' > data.json ``` -2. Run `generate_json.sh`: + **Windows PowerShell (`generate_json.ps1`)** + + ```powershell + # Ensure we are in a project root + if (-not (Test-Path "package.json")) { + Write-Error "Error: package.json not found." + exit 1 + } + + # Extract data (requires jq installed, or you can use ConvertFrom-Json) + $output = gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | ConvertFrom-Json + $output.response | Out-File -FilePath data.json -Encoding utf8 + ``` + +2. Run the script: + + **macOS/Linux** ```bash chmod +x generate_json.sh ./generate_json.sh ``` + **Windows (PowerShell)** + + ```powershell + .\generate_json.ps1 + ``` + 3. Check `data.json`. The file should look like this: ```json @@ -129,8 +187,10 @@ Use headless mode to perform custom, automated AI tasks. ### Scenario: Create a "Smart Commit" alias -You can add a function to your shell configuration (like `.zshrc` or `.bashrc`) -to create a `git commit` wrapper that writes the message for you. +You can add a function to your shell configuration to create a `git commit` +wrapper that writes the message for you. + +**macOS/Linux (Bash/Zsh)** 1. Open your `.zshrc` file (or `.bashrc` if you use Bash) in your preferred text editor. @@ -155,7 +215,7 @@ to create a `git commit` wrapper that writes the message for you. # Ask Gemini to write the message echo "Generating commit message..." - msg=$(echo "$diff" | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message.") + msg=$(echo "$diff" | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message.") # Commit with the generated message git commit -m "$msg" @@ -170,6 +230,43 @@ to create a `git commit` wrapper that writes the message for you. source ~/.zshrc ``` +**Windows (PowerShell)** + +1. Open your PowerShell profile in your preferred text editor. + + ```powershell + notepad $PROFILE + ``` + +2. Scroll to the very bottom of the file and paste this code: + + ```powershell + function gcommit { + # Get the diff of staged changes + $diff = git diff --staged + + if (-not $diff) { + Write-Host "No staged changes to commit." + return + } + + # Ask Gemini to write the message + Write-Host "Generating commit message..." + $msg = $diff | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message." + + # Commit with the generated message + git commit -m "$msg" + } + ``` + + Save your file and exit. + +3. Run this command to make the function available immediately: + + ```powershell + . $PROFILE + ``` + 4. Use your new command: ```bash diff --git a/docs/cli/tutorials/file-management.md b/docs/cli/tutorials/file-management.md index 0f4fa09575..37112d3bc7 100644 --- a/docs/cli/tutorials/file-management.md +++ b/docs/cli/tutorials/file-management.md @@ -7,9 +7,9 @@ create files, and control what Gemini CLI can see. ## Prerequisites - Gemini CLI installed and authenticated. -- A project directory to work with (e.g., a git repository). +- A project directory to work with (for example, a git repository). -## How to give the agent context (Reading files) +## Providing context by reading files Gemini CLI will generally try to read relevant files, sometimes prompting you for access (depending on your settings). To ensure that Gemini CLI uses a file, @@ -58,11 +58,13 @@ You know there's a `UserProfile` component, but you don't know where it lives. ``` Gemini uses the `glob` or `list_directory` tools to search your project -structure. It will return the specific path (e.g., +structure. It will return the specific path (for example, `src/components/UserProfile.tsx`), which you can then use with `@` in your next turn. -> **Tip:** You can also ask for lists of files, like "Show me all the TypeScript + +> [!TIP] +> You can also ask for lists of files, like "Show me all the TypeScript > configuration files in the root directory." ## How to modify code @@ -111,8 +113,8 @@ or, better yet, run your project's tests. `Run the tests for the UserProfile component.` ``` -Gemini CLI uses the `run_shell_command` tool to execute your test runner (e.g., -`npm test` or `jest`). This ensures the changes didn't break existing +Gemini CLI uses the `run_shell_command` tool to execute your test runner (for +example, `npm test` or `jest`). This ensures the changes didn't break existing functionality. ## Advanced: Controlling what Gemini sees diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 48c94cd78d..1eff7452ab 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -20,10 +20,18 @@ Most MCP servers require authentication. For GitHub, you need a PAT. **Read/Write** access to **Issues** and **Pull Requests**. 3. Store it in your environment: +**macOS/Linux** + ```bash export GITHUB_PERSONAL_ACCESS_TOKEN="github_pat_..." ``` +**Windows (PowerShell)** + +```powershell +$env:GITHUB_PERSONAL_ACCESS_TOKEN="github_pat_..." +``` + ## How to configure Gemini CLI You tell Gemini about new servers by editing your `settings.json`. @@ -44,7 +52,7 @@ You tell Gemini about new servers by editing your `settings.json`. "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/modelcontextprotocol/servers/github:latest" + "ghcr.io/github/github-mcp-server:latest" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" @@ -54,8 +62,10 @@ You tell Gemini about new servers by editing your `settings.json`. } ``` -> **Note:** The `command` is `docker`, and the rest are arguments passed to it. -> We map the local environment variable into the container so your secret isn't + +> [!NOTE] +> The `command` is `docker`, and the rest are arguments passed to it. We +> map the local environment variable into the container so your secret isn't > hardcoded in the config file. ## How to verify the connection @@ -81,7 +91,7 @@ don't need to learn special commands; just ask in natural language. The agent will: 1. Recognize the request matches a GitHub tool. -2. Call `github_list_pull_requests`. +2. Call `mcp_github_list_pull_requests`. 3. Present the data to you. ### Scenario: Creating an issue @@ -93,8 +103,8 @@ The agent will: - **Server won't start?** Try running the docker command manually in your terminal to see if it prints an error (e.g., "image not found"). -- **Tools not found?** Run `/mcp refresh` to force the CLI to re-query the - server for its capabilities. +- **Tools not found?** Run `/mcp reload` to force the CLI to re-query the server + for its capabilities. ## Next steps diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index 829fbecbd4..2268ebd923 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -11,8 +11,8 @@ persistent facts, and inspect the active context. ## Why manage context? -Out of the box, Gemini CLI is smart but generic. It doesn't know your preferred -testing framework, your indentation style, or that you hate using `any` in +Gemini CLI is powerful but general. It doesn't know your preferred testing +framework, your indentation style, or your preference against `any` in TypeScript. Context management solves this by giving the agent persistent memory. @@ -105,15 +105,15 @@ excellent for debugging why the agent might be ignoring a rule. If you edit a `GEMINI.md` file while a session is running, the agent won't know immediately. Force a reload with: -**Command:** `/memory refresh` +**Command:** `/memory reload` ## Best practices -- **Keep it focused:** Don't dump your entire internal wiki into `GEMINI.md`. - Keep instructions actionable and relevant to code generation. +- **Keep it focused:** Avoid adding excessive content to `GEMINI.md`. Keep + instructions actionable and relevant to code generation. - **Use negative constraints:** Explicitly telling the agent what _not_ to do - (e.g., "Do not use class components") is often more effective than vague - positive instructions. + (for example, "Do not use class components") is often more effective than + vague positive instructions. - **Review often:** Periodically check your `GEMINI.md` files to remove outdated rules. diff --git a/docs/cli/tutorials/plan-mode-steering.md b/docs/cli/tutorials/plan-mode-steering.md new file mode 100644 index 0000000000..0384425848 --- /dev/null +++ b/docs/cli/tutorials/plan-mode-steering.md @@ -0,0 +1,90 @@ +# Use Plan Mode with model steering for complex tasks + +Architecting a complex solution requires precision. By combining Plan Mode's +structured environment with model steering's real-time feedback, you can guide +Gemini CLI through the research and design phases to ensure the final +implementation plan is exactly what you need. + + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. + +## Prerequisites + +- Gemini CLI installed and authenticated. +- [Plan Mode](../plan-mode.md) enabled in your settings. +- [Model steering](../model-steering.md) enabled in your settings. + +## Why combine Plan Mode and model steering? + +[Plan Mode](../plan-mode.md) typically follows a linear path: research, propose, +and draft. Adding model steering lets you: + +1. **Direct the research:** Correct the agent if it's looking in the wrong + directory or missing a key dependency. +2. **Iterate mid-draft:** Suggest a different architectural pattern while the + agent is still writing the plan. +3. **Speed up the loop:** Avoid waiting for a full research turn to finish + before providing critical context. + +## Step 1: Start a complex task + +Enter Plan Mode and start a task that requires research. + +**Prompt:** `/plan I want to implement a new notification service using Redis.` + +Gemini CLI enters Plan Mode and starts researching your existing codebase to +identify where the new service should live. + +## Step 2: Steer the research phase + +As you see the agent calling tools like `list_directory` or `grep_search`, you +might realize it's missing the relevant context. + +**Action:** While the spinner is active, type your hint: +`"Don't forget to check packages/common/queues for the existing Redis config."` + +**Result:** Gemini CLI acknowledges your hint and immediately incorporates it +into its research. You'll see it start exploring the directory you suggested in +its very next turn. + +## Step 3: Refine the design mid-turn + +After research, the agent starts drafting the implementation plan. If you notice +it's proposing a design that doesn't align with your goals, steer it. + +**Action:** Type: +`"Actually, let's use a Publisher/Subscriber pattern instead of a simple queue for this service."` + +**Result:** The agent stops drafting the current version of the plan, +re-evaluates the design based on your feedback, and starts a new draft that uses +the Pub/Sub pattern. + +## Step 4: Approve and implement + +Once the agent has used your hints to craft the perfect plan, review the final +`.md` file. + +**Action:** Type: `"Looks perfect. Let's start the implementation."` + +Gemini CLI exits Plan Mode and transitions to the implementation phase. Because +the plan was refined in real-time with your feedback, the agent can now execute +each step with higher confidence and fewer errors. + +## Tips for effective steering + +- **Be specific:** Instead of "do it differently," try "use the existing + `Logger` class in `src/utils`." +- **Steer early:** Providing feedback during the research phase is more + efficient than waiting for the final plan to be drafted. +- **Use for context:** Steering is a great way to provide knowledge that might + not be obvious from reading the code (e.g., "We are planning to deprecate this + module next month"). + +## Next steps + +- Explore [Agent Skills](../skills.md) to add specialized expertise to your + planning turns. +- See the [Model steering reference](../model-steering.md) for technical + details. diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md index 5732fb1a58..464bd2eb89 100644 --- a/docs/cli/tutorials/session-management.md +++ b/docs/cli/tutorials/session-management.md @@ -104,9 +104,9 @@ Gemini gives you granular control over the undo process. You can choose to: Sometimes you want to try two different approaches to the same problem. 1. Start a session and get to a decision point. -2. Save the current state with `/chat save decision-point`. +2. Save the current state with `/resume save decision-point`. 3. Try your first approach. -4. Later, use `/chat resume decision-point` to fork the conversation back to +4. Later, use `/resume resume decision-point` to fork the conversation back to that moment and try a different approach. This creates a new branch of history without losing your original work. @@ -116,5 +116,5 @@ This creates a new branch of history without losing your original work. - Learn about [Checkpointing](../../cli/checkpointing.md) to understand the underlying safety mechanism. - Explore [Task planning](task-planning.md) to keep complex sessions organized. -- See the [Command reference](../../reference/commands.md) for all `/chat` and - `/resume` options. +- See the [Command reference](../../reference/commands.md) for `/resume` + options, grouped checkpoint menus, and `/chat` compatibility aliases. diff --git a/docs/cli/tutorials/shell-commands.md b/docs/cli/tutorials/shell-commands.md index 22e945407e..390c8acab9 100644 --- a/docs/cli/tutorials/shell-commands.md +++ b/docs/cli/tutorials/shell-commands.md @@ -7,7 +7,7 @@ automate complex workflows, and manage background processes safely. ## Prerequisites - Gemini CLI installed and authenticated. -- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, etc.). +- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, and so on). ## How to run commands directly (`!`) @@ -17,9 +17,10 @@ prefix. **Example:** `!ls -la` -This executes `ls -la` immediately and prints the output to your terminal. The -AI doesn't "see" this output unless you paste it back into the chat or use it in -a prompt. +This executes `ls -la` immediately and prints the output to your terminal. +Gemini CLI also records the command and its output in the current session +context, so the model can reference it in follow-up prompts. Very large outputs +may be truncated. ### Scenario: Entering Shell mode @@ -48,7 +49,7 @@ You want to run tests and fix any failures. 6. Gemini uses `replace` to fix the bug. 7. Gemini runs `npm test` again to verify the fix. -This loop turns Gemini into an autonomous engineer. +This loop lets Gemini work autonomously. ## How to manage background processes @@ -74,7 +75,7 @@ confirmation prompts) by streaming the output to you. However, for highly interactive tools (like `vim` or `top`), it's often better to run them yourself in a separate terminal window or use the `!` prefix. -## Safety first +## Safety features Giving an AI access to your shell is powerful but risky. Gemini CLI includes several safety layers. diff --git a/docs/cli/tutorials/skills-getting-started.md b/docs/cli/tutorials/skills-getting-started.md index 2614a679ef..ee59641d21 100644 --- a/docs/cli/tutorials/skills-getting-started.md +++ b/docs/cli/tutorials/skills-getting-started.md @@ -14,10 +14,18 @@ responding correctly. 1. Run the following command to create the folders: + **macOS/Linux** + ```bash mkdir -p .gemini/skills/api-auditor/scripts ``` + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path ".gemini\skills\api-auditor\scripts" + ``` + ### Create the definition 1. Create a file at `.gemini/skills/api-auditor/SKILL.md`. This tells the agent diff --git a/docs/core/index.md b/docs/core/index.md index 53aa647dc2..afa13787b8 100644 --- a/docs/core/index.md +++ b/docs/core/index.md @@ -9,12 +9,14 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the - **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use specialized sub-agents for complex tasks. -- **[Core tools API](../reference/tools-api.md):** Information on how tools are - defined, registered, and used by the core. +- **[Core tools reference](../reference/tools.md):** Information on how tools + are defined, registered, and used by the core. - **[Memory Import Processor](../reference/memport.md):** Documentation for the modular GEMINI.md import feature using @file.md syntax. - **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for fine-grained control over tool execution. +- **[Local Model Routing (experimental)](./local-model-routing.md):** Learn how + to enable use of a local Gemma model for model routing decisions. ## Role of the core diff --git a/docs/core/local-model-routing.md b/docs/core/local-model-routing.md new file mode 100644 index 0000000000..99f52511b0 --- /dev/null +++ b/docs/core/local-model-routing.md @@ -0,0 +1,193 @@ +# Local Model Routing (experimental) + +Gemini CLI supports using a local model for +[routing decisions](../cli/model-routing.md). When configured, Gemini CLI will +use a locally-running **Gemma** model to make routing decisions (instead of +sending routing decisions to a hosted model). + +This feature can help reduce costs associated with hosted model usage while +offering similar routing decision latency and quality. + +> **Note: Local model routing is currently an experimental feature.** + +## Setup + +Using a Gemma model for routing decisions requires that an implementation of a +Gemma model be running locally on your machine, served behind an HTTP endpoint +and accessed via the Gemini API. + +To serve the Gemma model, follow these steps: + +### Download the LiteRT-LM runtime + +The [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) runtime offers +pre-built binaries for locally-serving models. Download the binary appropriate +for your system. + +#### Windows + +1. Download + [lit.windows_x86_64.exe](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.windows_x86_64.exe). +2. Using GPU on Windows requires the DirectXShaderCompiler. Download the + [dxc zip from the latest release](https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.8.2505.1/dxc_2025_07_14.zip). + Unzip the archive and from the architecture-appropriate `bin\` directory, and + copy the `dxil.dll` and `dxcompiler.dll` into the same location as you saved + `lit.windows_x86_64.exe`. +3. (Optional) Test starting the runtime: + `.\lit.windows_x86_64.exe serve --verbose` + +#### Linux + +1. Download + [lit.linux_x86_64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.linux_x86_64). +2. Ensure the binary is executable: `chmod a+x lit.linux_x86_64` +3. (Optional) Test starting the runtime: `./lit.linux_x86_64 serve --verbose` + +#### MacOS + +1. Download + [lit-macos-arm64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.macos_arm64). +2. Ensure the binary is executable: `chmod a+x lit.macos_arm64` +3. (Optional) Test starting the runtime: `./lit.macos_arm64 serve --verbose` + +> **Note**: MacOS can be configured to only allows binaries from "App Store & +> Known Developers". If you encounter an error message when attempting to run +> the binary, you will need to allow the application. One option is to visit +> `System Settings -> Privacy & Security`, scroll to `Security`, and click +> `"Allow Anyway"` for `"lit.macos_arm64"`. Another option is to run +> `xattr -d com.apple.quarantine lit.macos_arm64` from the commandline. + +### Download the Gemma Model + +Before using Gemma, you will need to download the model (and agree to the Terms +of Service). + +This can be done via the LiteRT-LM runtime. + +#### Windows + +```bash +$ .\lit.windows_x86_64.exe pull gemma3-1b-gpu-custom + +[Legal] The model you are about to download is governed by +the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing. + +Full Terms: https://ai.google.dev/gemma/terms +Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy + +Do you accept these terms? (Y/N): Y + +Terms accepted. +Downloading model 'gemma3-1b-gpu-custom' ... +Downloading... 968.6 MB +Download complete. +``` + +#### Linux + +```bash +$ ./lit.linux_x86_64 pull gemma3-1b-gpu-custom + +[Legal] The model you are about to download is governed by +the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing. + +Full Terms: https://ai.google.dev/gemma/terms +Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy + +Do you accept these terms? (Y/N): Y + +Terms accepted. +Downloading model 'gemma3-1b-gpu-custom' ... +Downloading... 968.6 MB +Download complete. +``` + +#### MacOS + +```bash +$ ./lit.lit.macos_arm64 pull gemma3-1b-gpu-custom + +[Legal] The model you are about to download is governed by +the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing. + +Full Terms: https://ai.google.dev/gemma/terms +Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy + +Do you accept these terms? (Y/N): Y + +Terms accepted. +Downloading model 'gemma3-1b-gpu-custom' ... +Downloading... 968.6 MB +Download complete. +``` + +### Start LiteRT-LM Runtime + +Using the command appropriate to your system, start the LiteRT-LM runtime. +Configure the port that you want to use for your Gemma model. For the purposes +of this document, we will use port `9379`. + +Example command for MacOS: `./lit.macos_arm64 serve --port=9379 --verbose` + +### (Optional) Verify Model Serving + +Send a quick prompt to the model via HTTP to validate successful model serving. +This will cause the runtime to download the model and run it once. + +You should see a short joke in the server output as an indicator of success. + +#### Windows + +``` +# Run this in PowerShell to send a request to the server + +$uri = "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent" +$body = @{contents = @( @{ + role = "user" + parts = @( @{ text = "Tell me a joke." } ) +})} | ConvertTo-Json -Depth 10 + +Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType "application/json" +``` + +#### Linux/MacOS + +```bash +$ curl "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent" \ + -H 'Content-Type: application/json' \ + -X POST \ + -d '{"contents":[{"role":"user","parts":[{"text":"Tell me a joke."}]}]}' +``` + +## Configuration + +To use a local Gemma model for routing, you must explicitly enable it in your +`settings.json`: + +```json +{ + "experimental": { + "gemmaModelRouter": { + "enabled": true, + "classifier": { + "host": "http://localhost:9379", + "model": "gemma3-1b-gpu-custom" + } + } + } +} +``` + +> Use the port you started your LiteRT-LM runtime on in the setup steps. + +### Configuration schema + +| Field | Type | Required | Description | +| :----------------- | :------ | :------- | :----------------------------------------------------------------------------------------- | +| `enabled` | boolean | Yes | Must be `true` to enable the feature. | +| `classifier` | object | Yes | The configuration for the local model endpoint. It includes the host and model specifiers. | +| `classifier.host` | string | Yes | The URL to the local model server. Should be `http://localhost:`. | +| `classifier.model` | string | Yes | The model name to use for decisions. Must be `"gemma3-1b-gpu-custom"`. | + +> **Note: You will need to restart after configuration changes for local model +> routing to take effect.** diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md index 3e5b8b06d1..2e34a9dbc4 100644 --- a/docs/core/remote-agents.md +++ b/docs/core/remote-agents.md @@ -10,7 +10,9 @@ agents in the following repositories: - [ADK Samples (Python)](https://github.com/google/adk-samples/tree/main/python) - [ADK Python Contributing Samples](https://github.com/google/adk-python/tree/main/contributing/samples) -> **Note: Remote subagents are currently an experimental feature.** + +> [!NOTE] +> Remote subagents are currently an experimental feature. ## Configuration @@ -25,6 +27,20 @@ To use remote subagents, you must explicitly enable them in your } ``` +## Proxy support + +Gemini CLI routes traffic to remote agents through an HTTP/HTTPS proxy if one is +configured. It uses the `general.proxy` setting in your `settings.json` file or +standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`). + +```json +{ + "general": { + "proxy": "http://my-proxy:8080" + } +} +``` + ## Defining remote subagents Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter. @@ -40,6 +56,7 @@ You can place them in: | `kind` | string | Yes | Must be `remote`. | | `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). | | `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. | +| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). | ### Single-subagent example @@ -67,18 +84,288 @@ Markdown file. --- ``` -> **Note:** Mixed local and remote agents, or multiple local agents, are not + +> [!NOTE] Mixed local and remote agents, or multiple local agents, are not > supported in a single file; the list format is currently remote-only. +## Authentication + +Many remote agents require authentication. Gemini CLI supports several +authentication methods aligned with the +[A2A security specification](https://a2a-protocol.org/latest/specification/#451-securityscheme). +Add an `auth` block to your agent's frontmatter to configure credentials. + +### Supported auth types + +Gemini CLI supports the following authentication types: + +| Type | Description | +| :------------------- | :--------------------------------------------------------------------------------------------- | +| `apiKey` | Send a static API key as an HTTP header. | +| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). | +| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. | +| `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. | + +### Dynamic values + +For `apiKey` and `http` auth types, secret values (`key`, `token`, `username`, +`password`, `value`) support dynamic resolution: + +| Format | Description | Example | +| :---------- | :-------------------------------------------------- | :------------------------- | +| `$ENV_VAR` | Read from an environment variable. | `$MY_API_KEY` | +| `!command` | Execute a shell command and use the trimmed output. | `!gcloud auth print-token` | +| literal | Use the string as-is. | `sk-abc123` | +| `$$` / `!!` | Escape prefix. `$$FOO` becomes the literal `$FOO`. | `$$NOT_AN_ENV_VAR` | + +> **Security tip:** Prefer `$ENV_VAR` or `!command` over embedding secrets +> directly in agent files, especially for project-level agents checked into +> version control. + +### API key (`apiKey`) + +Sends an API key as an HTTP header on every request. + +| Field | Type | Required | Description | +| :----- | :----- | :------- | :---------------------------------------------------- | +| `type` | string | Yes | Must be `apiKey`. | +| `key` | string | Yes | The API key value. Supports dynamic values. | +| `name` | string | No | Header name to send the key in. Default: `X-API-Key`. | + +```yaml +--- +kind: remote +name: my-agent +agent_card_url: https://example.com/agent-card +auth: + type: apiKey + key: $MY_API_KEY +--- +``` + +### HTTP authentication (`http`) + +Supports Bearer tokens, Basic auth, and arbitrary IANA-registered HTTP +authentication schemes. + +#### Bearer token + +Use the following fields to configure a Bearer token: + +| Field | Type | Required | Description | +| :------- | :----- | :------- | :----------------------------------------- | +| `type` | string | Yes | Must be `http`. | +| `scheme` | string | Yes | Must be `Bearer`. | +| `token` | string | Yes | The bearer token. Supports dynamic values. | + +```yaml +auth: + type: http + scheme: Bearer + token: $MY_BEARER_TOKEN +``` + +#### Basic authentication + +Use the following fields to configure Basic authentication: + +| Field | Type | Required | Description | +| :--------- | :----- | :------- | :------------------------------------- | +| `type` | string | Yes | Must be `http`. | +| `scheme` | string | Yes | Must be `Basic`. | +| `username` | string | Yes | The username. Supports dynamic values. | +| `password` | string | Yes | The password. Supports dynamic values. | + +```yaml +auth: + type: http + scheme: Basic + username: $MY_USERNAME + password: $MY_PASSWORD +``` + +#### Raw scheme + +For any other IANA-registered scheme (for example, Digest, HOBA), provide the +raw authorization value. + +| Field | Type | Required | Description | +| :------- | :----- | :------- | :---------------------------------------------------------------------------- | +| `type` | string | Yes | Must be `http`. | +| `scheme` | string | Yes | The scheme name (for example, `Digest`). | +| `value` | string | Yes | Raw value sent as `Authorization: `. Supports dynamic values. | + +```yaml +auth: + type: http + scheme: Digest + value: $MY_DIGEST_VALUE +``` + +### Google Application Default Credentials (`google-credentials`) + +Uses +[Google Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials) +to authenticate with Google Cloud services and Cloud Run endpoints. This is the +recommended auth method for agents hosted on Google Cloud infrastructure. + +| Field | Type | Required | Description | +| :------- | :------- | :------- | :-------------------------------------------------------------------------- | +| `type` | string | Yes | Must be `google-credentials`. | +| `scopes` | string[] | No | OAuth scopes. Defaults to `https://www.googleapis.com/auth/cloud-platform`. | + +```yaml +--- +kind: remote +name: my-gcp-agent +agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json +auth: + type: google-credentials +--- +``` + +#### How token selection works + +The provider automatically selects the correct token type based on the agent's +host: + +| Host pattern | Token type | Use case | +| :----------------- | :----------------- | :------------------------------------------ | +| `*.googleapis.com` | **Access token** | Google APIs (Agent Engine, Vertex AI, etc.) | +| `*.run.app` | **Identity token** | Cloud Run services | + +- **Access tokens** authorize API calls to Google services. They are scoped + (default: `cloud-platform`) and fetched via `GoogleAuth.getClient()`. +- **Identity tokens** prove the caller's identity to a service that validates + the token's audience. The audience is set to the target host. These are + fetched via `GoogleAuth.getIdTokenClient()`. + +Both token types are cached and automatically refreshed before expiry. + +#### Setup + +`google-credentials` relies on ADC, which means your environment must have +credentials configured. Common setups: + +- **Local development:** Run `gcloud auth application-default login` to + authenticate with your Google account. +- **CI / Cloud environments:** Use a service account. Set the + `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your + service account key file, or use workload identity on GKE / Cloud Run. + +#### Allowed hosts + +For security, `google-credentials` only sends tokens to known Google-owned +hosts: + +- `*.googleapis.com` +- `*.run.app` + +Requests to any other host will be rejected with an error. If your agent is +hosted on a different domain, use one of the other auth types (`apiKey`, `http`, +or `oauth2`). + +#### Examples + +The following examples demonstrate how to configure Google Application Default +Credentials. + +**Cloud Run agent:** + +```yaml +--- +kind: remote +name: cloud-run-agent +agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json +auth: + type: google-credentials +--- +``` + +**Google API with custom scopes:** + +```yaml +--- +kind: remote +name: vertex-agent +agent_card_url: https://us-central1-aiplatform.googleapis.com/.well-known/agent.json +auth: + type: google-credentials + scopes: + - https://www.googleapis.com/auth/cloud-platform + - https://www.googleapis.com/auth/compute +--- +``` + +### OAuth 2.0 (`oauth2`) + +Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first +use, Gemini CLI opens your browser for sign-in and persists the resulting tokens +for subsequent requests. + +| Field | Type | Required | Description | +| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | string | Yes | Must be `oauth2`. | +| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. | +| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. | +| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. | +| `authorization_url` | string | No | Authorization endpoint. Discovered from the agent card if omitted. | +| `token_url` | string | No | Token endpoint. Discovered from the agent card if omitted. | + +```yaml +--- +kind: remote +name: oauth-agent +agent_card_url: https://example.com/.well-known/agent.json +auth: + type: oauth2 + client_id: my-client-id.apps.example.com +--- +``` + +If the agent card advertises an `oauth2` security scheme with +`authorizationCode` flow, the `authorization_url`, `token_url`, and `scopes` are +automatically discovered. You only need to provide `client_id` (and +`client_secret` if required). + +Tokens are persisted to disk and refreshed automatically when they expire. + +### Auth validation + +When Gemini CLI loads a remote agent, it validates your auth configuration +against the agent card's declared `securitySchemes`. If the agent requires +authentication that you haven't configured, you'll see an error describing +what's needed. + +`google-credentials` is treated as compatible with `http` Bearer security +schemes, since it produces Bearer tokens. + +### Auth retry behavior + +All auth providers automatically retry on `401` and `403` responses by +re-fetching credentials (up to 2 retries). This handles cases like expired +tokens or rotated credentials. For `apiKey` with `!command` values, the command +is re-executed on retry to fetch a fresh key. + +### Agent card fetching and auth + +When connecting to a remote agent, Gemini CLI first fetches the agent card +**without** authentication. If the card endpoint returns a `401` or `403`, it +retries the fetch **with** the configured auth headers. This lets agents have +publicly accessible cards while protecting their task endpoints, or to protect +both behind auth. + ## Managing Subagents Users can manage subagents using the following commands within the Gemini CLI: - `/agents list`: Displays all available local and remote subagents. -- `/agents refresh`: Reloads the agent registry. Use this after adding or +- `/agents reload`: Reloads the agent registry. Use this after adding or modifying agent definition files. - `/agents enable `: Enables a specific subagent. - `/agents disable `: Disables a specific subagent. -> **Tip:** You can use the `@cli_help` agent within Gemini CLI for assistance + +> [!TIP] +> You can use the `@cli_help` agent within Gemini CLI for assistance > with configuring subagents. diff --git a/docs/core/subagents.md b/docs/core/subagents.md index e84f46dd8c..b0cffca3b5 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -5,22 +5,18 @@ session. They are designed to handle specific, complex tasks—like deep codebas analysis, documentation lookup, or domain-specific reasoning—without cluttering the main agent's context or toolset. -> **Note: Subagents are currently an experimental feature.** -> -> To use custom subagents, you must explicitly enable them in your -> `settings.json`: -> -> ```json -> { -> "experimental": { "enableAgents": true } -> } -> ``` -> -> **Warning:** Subagents currently operate in -> ["YOLO mode"](../reference/configuration.md#command-line-arguments), meaning -> they may execute tools without individual user confirmation for each step. -> Proceed with caution when defining agents with powerful tools like -> `run_shell_command` or `write_file`. + +> [!NOTE] +> Subagents are currently an experimental feature. +> +To use custom subagents, you must ensure they are enabled in your +`settings.json` (enabled by default): + +```json +{ + "experimental": { "enableAgents": true } +} +``` ## What are subagents? @@ -38,6 +34,34 @@ main agent calls the tool, it delegates the task to the subagent. Once the subagent completes its task, it reports back to the main agent with its findings. +## How to use subagents + +You can use subagents through automatic delegation or by explicitly forcing them +in your prompt. + +### Automatic delegation + +Gemini CLI's main agent is instructed to use specialized subagents when a task +matches their expertise. For example, if you ask "How does the auth system +work?", the main agent may decide to call the `codebase_investigator` subagent +to perform the research. + +### Forcing a subagent (@ syntax) + +You can explicitly direct a task to a specific subagent by using the `@` symbol +followed by the subagent's name at the beginning of your prompt. This is useful +when you want to bypass the main agent's decision-making and go straight to a +specialist. + +**Example:** + +```bash +@codebase_investigator Map out the relationship between the AgentRegistry and the LocalAgentExecutor. +``` + +When you use the `@` syntax, the CLI injects a system note that nudges the +primary model to use that specific subagent tool immediately. + ## Built-in subagents Gemini CLI comes with the following built-in subagents: @@ -49,15 +73,17 @@ Gemini CLI comes with the following built-in subagents: dependencies. - **When to use:** "How does the authentication system work?", "Map out the dependencies of the `AgentRegistry` class." -- **Configuration:** Enabled by default. You can configure it in - `settings.json`. Example (forcing a specific model): +- **Configuration:** Enabled by default. You can override its settings in + `settings.json` under `agents.overrides`. Example (forcing a specific model + and increasing turns): ```json { - "experimental": { - "codebaseInvestigatorSettings": { - "enabled": true, - "maxNumTurns": 20, - "model": "gemini-2.5-pro" + "agents": { + "overrides": { + "codebase_investigator": { + "modelConfig": { "model": "gemini-3-flash-preview" }, + "runConfig": { "maxTurns": 50 } + } } } } @@ -90,7 +116,9 @@ Gemini CLI comes with the following built-in subagents: the pricing table from this page," "Click the login button and enter my credentials." -> **Note:** This is a preview feature currently under active development. + +> [!NOTE] +> This is a preview feature currently under active development. #### Prerequisites @@ -193,8 +221,10 @@ captures a screenshot and sends it to the vision model for analysis. The model returns coordinates and element descriptions that the browser agent uses with the `click_at` tool for precise, coordinate-based interactions. -> **Note:** The visual agent requires API key or Vertex AI authentication. It is -> not available when using Google Login. + +> [!NOTE] +> The visual agent requires API key or Vertex AI authentication. It is +> not available when using "Sign in with Google". ## Creating custom subagents @@ -233,7 +263,7 @@ kind: local tools: - read_file - grep_search -model: gemini-2.5-pro +model: gemini-3-flash-preview temperature: 0.2 max_turns: 10 --- @@ -254,16 +284,102 @@ it yourself; just report it. ### Configuration schema -| Field | Type | Required | Description | -| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------ | -| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. | -| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. | -| `kind` | string | No | `local` (default) or `remote`. | -| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. | -| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). | -| `temperature` | number | No | Model temperature (0.0 - 2.0). | -| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. | -| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. | +| Field | Type | Required | Description | +| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. | +| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. | +| `kind` | string | No | `local` (default) or `remote`. | +| `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** | +| `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). | +| `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. | +| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. | +| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. | + +### Tool wildcards + +When defining `tools` for a subagent, you can use wildcards to quickly grant +access to groups of tools: + +- `*`: Grant access to all available built-in and discovered tools. +- `mcp_*`: Grant access to all tools from all connected MCP servers. +- `mcp_my-server_*`: Grant access to all tools from a specific MCP server named + `my-server`. + +### Isolation and recursion protection + +Each subagent runs in its own isolated context loop. This means: + +- **Independent history:** The subagent's conversation history does not bloat + the main agent's context. +- **Isolated tools:** The subagent only has access to the tools you explicitly + grant it. +- **Recursion protection:** To prevent infinite loops and excessive token usage, + subagents **cannot** call other subagents. If a subagent is granted the `*` + tool wildcard, it will still be unable to see or invoke other agents. + +## Managing subagents + +You can manage subagents interactively using the `/agents` command or +persistently via `settings.json`. + +### Interactive management (/agents) + +If you are in an interactive CLI session, you can use the `/agents` command to +manage subagents without editing configuration files manually. This is the +recommended way to quickly enable, disable, or re-configure agents on the fly. + +For a full list of sub-commands and usage, see the +[`/agents` command reference](../reference/commands.md#agents). + +### Persistent configuration (settings.json) + +While the `/agents` command and agent definition files provide a starting point, +you can use `settings.json` for global, persistent overrides. This is useful for +enforcing specific models or execution limits across all sessions. + +#### `agents.overrides` + +Use this to enable or disable specific agents or override their run +configurations. + +```json +{ + "agents": { + "overrides": { + "security-auditor": { + "enabled": false, + "runConfig": { + "maxTurns": 20, + "maxTimeMinutes": 10 + } + } + } + } +} +``` + +#### `modelConfigs.overrides` + +You can target specific subagents with custom model settings (like system +instruction prefixes or specific safety settings) using the `overrideScope` +field. + +```json +{ + "modelConfigs": { + "overrides": [ + { + "match": { "overrideScope": "security-auditor" }, + "modelConfig": { + "generateContentConfig": { + "temperature": 0.1 + } + } + } + ] + } +} +``` ### Optimizing your subagent @@ -295,10 +411,12 @@ that your subagent was called with a specific prompt and the given description. Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent (A2A) protocol. -> **Note: Remote subagents are currently an experimental feature.** + +> [!NOTE] +> Remote subagents are currently an experimental feature. -See the [Remote Subagents documentation](/docs/core/remote-agents) for detailed -configuration and usage instructions. +See the [Remote Subagents documentation](remote-agents) for detailed +configuration, authentication, and usage instructions. ## Extension subagents diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index d36df94d78..56c51d30df 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -23,7 +23,7 @@ Gemini CLI creates a copy of the extension during installation. You must run GitHub, you must have `git` installed on your machine. ```bash -gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] +gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] [--skip-settings] ``` - ``: The GitHub URL or local path of the extension. @@ -31,6 +31,7 @@ gemini extensions install [--ref ] [--auto-update] [--pre-release] - `--auto-update`: Enable automatic updates for this extension. - `--pre-release`: Enable installation of pre-release versions. - `--consent`: Acknowledge security risks and skip the confirmation prompt. +- `--skip-settings`: Skip the configuration on install process. ### Uninstall an extension @@ -122,7 +123,11 @@ The manifest file defines the extension's behavior and configuration. } }, "contextFileName": "GEMINI.md", - "excludeTools": ["run_shell_command"] + "excludeTools": ["run_shell_command"], + "migratedTo": "https://github.com/new-owner/new-extension-repo", + "plan": { + "directory": ".gemini/plans" + } } ``` @@ -135,6 +140,9 @@ The manifest file defines the extension's behavior and configuration. - `version`: The version of the extension. - `description`: A short description of the extension. This will be displayed on [geminicli.com/extensions](https://geminicli.com/extensions). +- `migratedTo`: The URL of the new repository source for the extension. If this + is set, the CLI will automatically check this new source for updates and + migrate the extension's installation to the new source if an update is found. - `mcpServers`: A map of MCP servers to settings. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers defined in a @@ -157,6 +165,11 @@ The manifest file defines the extension's behavior and configuration. `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. +- `plan`: Planning features configuration. + - `directory`: The directory where planning artifacts are stored. This serves + as a fallback if the user hasn't specified a plan directory in their + settings. If not specified by either the extension or the user, the default + is `~/.gemini/tmp///plans/`. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes @@ -222,11 +235,53 @@ skill definitions in a `skills/` directory. For example, ### Sub-agents -> **Note:** Sub-agents are a preview feature currently under active development. + +> [!NOTE] +> Sub-agents are a preview feature currently under active development. Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add agent definition files (`.md`) to an `agents/` directory in your extension root. +### Policy Engine + +Extensions can contribute policy rules and safety checkers to the Gemini CLI +[Policy Engine](../reference/policy-engine.md). These rules are defined in +`.toml` files and take effect when the extension is activated. + +To add policies, create a `policies/` directory in your extension's root and +place your `.toml` policy files inside it. Gemini CLI automatically loads all +`.toml` files from this directory. + +Rules contributed by extensions run in their own tier (tier 2), alongside +workspace-defined policies. This tier has higher priority than the default rules +but lower priority than user or admin policies. + + +> [!WARNING] +> For security, Gemini CLI ignores any `allow` decisions or `yolo` +> mode configurations in extension policies. This ensures that an extension +> cannot automatically approve tool calls or bypass security measures without +> your confirmation. + +**Example `policies.toml`** + +```toml +[[rule]] +mcpName = "my_server" +toolName = "dangerous_tool" +decision = "ask_user" +priority = 100 + +[[safety_checker]] +mcpName = "my_server" +toolName = "write_data" +priority = 200 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +required_context = ["environment"] +``` + ### Themes Extensions can provide custom themes to personalize the CLI UI. Themes are diff --git a/docs/extensions/releasing.md b/docs/extensions/releasing.md index f29a1eac6e..cb19c351a8 100644 --- a/docs/extensions/releasing.md +++ b/docs/extensions/releasing.md @@ -152,3 +152,29 @@ jobs: release/linux.arm64.my-tool.tar.gz release/win32.arm64.my-tool.zip ``` + +## Migrating an Extension Repository + +If you need to move your extension to a new repository (e.g., from a personal +account to an organization) or rename it, you can use the `migratedTo` property +in your `gemini-extension.json` file to seamlessly transition your users. + +1. **Create the new repository**: Setup your extension in its new location. +2. **Update the old repository**: In your original repository, update the + `gemini-extension.json` file to include the `migratedTo` property, pointing + to the new repository URL, and bump the version number. You can optionally + change the `name` of your extension at this time in the new repository. + ```json + { + "name": "my-extension", + "version": "1.1.0", + "migratedTo": "https://github.com/new-owner/new-extension-repo" + } + ``` +3. **Release the update**: Publish this new version in your old repository. + +When users check for updates, the Gemini CLI will detect the `migratedTo` field, +verify that the new repository contains a valid extension update, and +automatically update their local installation to track the new source and name +moving forward. All extension settings will automatically migrate to the new +installation. diff --git a/docs/extensions/writing-extensions.md b/docs/extensions/writing-extensions.md index 213d77542e..b22f69e672 100644 --- a/docs/extensions/writing-extensions.md +++ b/docs/extensions/writing-extensions.md @@ -189,10 +189,18 @@ Custom commands create shortcuts for complex prompts. 1. Create a `commands` directory and a subdirectory for your command group: + **macOS/Linux** + ```bash mkdir -p commands/fs ``` + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "commands\fs" + ``` + 2. Create a file named `commands/fs/grep-code.toml`: ```toml @@ -252,10 +260,18 @@ Skills are activated only when needed, which saves context tokens. 1. Create a `skills` directory and a subdirectory for your skill: + **macOS/Linux** + ```bash mkdir -p skills/security-audit ``` + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "skills\security-audit" + ``` + 2. Create a `skills/security-audit/SKILL.md` file: ```markdown diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index e8696137cf..6d8758b958 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -4,6 +4,12 @@ To use Gemini CLI, you'll need to authenticate with Google. This guide helps you quickly find the best way to sign in based on your account type and how you're using the CLI. + +> [!TIP] +> Looking for a high-level comparison of all available subscriptions? +> To compare features and find the right quota for your needs, see our +> [Plans page](https://geminicli.com/plans/). + For most users, we recommend starting Gemini CLI and logging in with your personal Google account. @@ -13,8 +19,8 @@ Select the authentication method that matches your situation in the table below: | User Type / Scenario | Recommended Authentication Method | Google Cloud Project Required | | :--------------------------------------------------------------------- | :--------------------------------------------------------------- | :---------------------------------------------------------- | -| Individual Google accounts | [Login with Google](#login-google) | No, with exceptions | -| Organization users with a company, school, or Google Workspace account | [Login with Google](#login-google) | [Yes](#set-gcp) | +| Individual Google accounts | [Sign in with Google](#login-google) | No, with exceptions | +| Organization users with a company, school, or Google Workspace account | [Sign in with Google](#login-google) | [Yes](#set-gcp) | | AI Studio user with a Gemini API key | [Use Gemini API Key](#gemini-api) | No | | Google Cloud Vertex AI user | [Vertex AI](#vertex-ai) | [Yes](#set-gcp) | | [Headless mode](#headless) | [Use Gemini API Key](#gemini-api) or
[Vertex AI](#vertex-ai) | No (for Gemini API Key)
[Yes](#set-gcp) (for Vertex AI) | @@ -32,15 +38,15 @@ Select the authentication method that matches your situation in the table below: [Google AI Ultra for Business](https://support.google.com/a/answer/16345165) subscriptions. -## (Recommended) Login with Google +## (Recommended) Sign in with Google If you run Gemini CLI on your local machine, the simplest authentication method is logging in with your Google account. This method requires a web browser on a -machine that can communicate with the terminal running Gemini CLI (e.g., your -local machine). +machine that can communicate with the terminal running Gemini CLI (for example, +your local machine). -> **Important:** If you are a **Google AI Pro** or **Google AI Ultra** -> subscriber, use the Google account associated with your subscription. +If you are a **Google AI Pro** or **Google AI Ultra** subscriber, use the Google +account associated with your subscription. To authenticate and use Gemini CLI: @@ -50,9 +56,9 @@ To authenticate and use Gemini CLI: gemini ``` -2. Select **Login with Google**. Gemini CLI opens a login prompt using your web - browser. Follow the on-screen instructions. Your credentials will be cached - locally for future sessions. +2. Select **Sign in with Google**. Gemini CLI opens a sign in prompt using your + web browser. Follow the on-screen instructions. Your credentials will be + cached locally for future sessions. ### Do I need to set my Google Cloud project? @@ -78,11 +84,20 @@ To authenticate and use Gemini CLI with a Gemini API key: 2. Set the `GEMINI_API_KEY` environment variable to your key. For example: + **macOS/Linux** + ```bash # Replace YOUR_GEMINI_API_KEY with the key from AI Studio export GEMINI_API_KEY="YOUR_GEMINI_API_KEY" ``` + **Windows (PowerShell)** + + ```powershell + # Replace YOUR_GEMINI_API_KEY with the key from AI Studio + $env:GEMINI_API_KEY="YOUR_GEMINI_API_KEY" + ``` + To make this setting persistent, see [Persisting Environment Variables](#persisting-vars). @@ -94,7 +109,9 @@ To authenticate and use Gemini CLI with a Gemini API key: 4. Select **Use Gemini API key**. -> **Warning:** Treat API keys, especially for services like Gemini, as sensitive + +> [!WARNING] +> Treat API keys, especially for services like Gemini, as sensitive > credentials. Protect them to prevent unauthorized access and potential misuse > of the service under your account. @@ -114,12 +131,22 @@ or the location where you want to run your jobs. For example: +**macOS/Linux** + ```bash -# Replace with your project ID and desired location (e.g., us-central1) +# Replace with your project ID and desired location (for example, us-central1) export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` +**Windows (PowerShell)** + +```powershell +# Replace with your project ID and desired location (for example, us-central1) +$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" +$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" +``` + To make any Vertex AI environment variable settings persistent, see [Persisting Environment Variables](#persisting-vars). @@ -127,12 +154,20 @@ To make any Vertex AI environment variable settings persistent, see Consider this authentication method if you have Google Cloud CLI installed. -> **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you -> must unset them to use ADC: -> -> ```bash -> unset GOOGLE_API_KEY GEMINI_API_KEY -> ``` +If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset +them to use ADC. + +**macOS/Linux** + +```bash +unset GOOGLE_API_KEY GEMINI_API_KEY +``` + +**Windows (PowerShell)** + +```powershell +Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +``` 1. Verify you have a Google Cloud project and Vertex AI API is enabled. @@ -157,12 +192,20 @@ Consider this authentication method if you have Google Cloud CLI installed. Consider this method of authentication in non-interactive environments, CI/CD pipelines, or if your organization restricts user-based ADC or API key creation. -> **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you -> must unset them: -> -> ```bash -> unset GOOGLE_API_KEY GEMINI_API_KEY -> ``` +If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset +them: + +**macOS/Linux** + +```bash +unset GOOGLE_API_KEY GEMINI_API_KEY +``` + +**Windows (PowerShell)** + +```powershell +Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +``` 1. [Create a service account and key](https://cloud.google.com/iam/docs/keys-create-delete) and download the provided JSON file. Assign the "Vertex AI User" role to the @@ -171,11 +214,20 @@ pipelines, or if your organization restricts user-based ADC or API key creation. 2. Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the JSON file's absolute path. For example: + **macOS/Linux** + ```bash # Replace /path/to/your/keyfile.json with the actual path export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/keyfile.json" ``` + **Windows (PowerShell)** + + ```powershell + # Replace C:\path\to\your\keyfile.json with the actual path + $env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\keyfile.json" + ``` + 3. [Configure your Google Cloud Project](#set-gcp). 4. Start the CLI: @@ -185,8 +237,11 @@ pipelines, or if your organization restricts user-based ADC or API key creation. ``` 5. Select **Vertex AI**. - > **Warning:** Protect your service account key file as it gives access to - > your resources. + + +> [!WARNING] +> Protect your service account key file as it gives access to +> your resources. #### C. Vertex AI - Google Cloud API key @@ -195,15 +250,23 @@ pipelines, or if your organization restricts user-based ADC or API key creation. 2. Set the `GOOGLE_API_KEY` environment variable: + **macOS/Linux** + ```bash # Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" ``` - > **Note:** If you see errors like - > `"API keys are not supported by this API..."`, your organization might - > restrict API key usage for this service. Try the other Vertex AI - > authentication methods instead. + **Windows (PowerShell)** + + ```powershell + # Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key + $env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" + ``` + + If you see errors like `"API keys are not supported by this API..."`, your + organization might restrict API key usage for this service. Try the other + Vertex AI authentication methods instead. 3. [Configure your Google Cloud Project](#set-gcp). @@ -217,7 +280,9 @@ pipelines, or if your organization restricts user-based ADC or API key creation. ## Set your Google Cloud project -> **Important:** Most individual Google accounts (free and paid) don't require a + +> [!IMPORTANT] +> Most individual Google accounts (free and paid) don't require a > Google Cloud project for authentication. When you sign in using your Google account, you may need to configure a Google @@ -243,11 +308,20 @@ To configure Gemini CLI to use a Google Cloud project, do the following: For example, to set the `GOOGLE_CLOUD_PROJECT_ID` variable: + **macOS/Linux** + ```bash # Replace YOUR_PROJECT_ID with your actual Google Cloud project ID export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" ``` + **Windows (PowerShell)** + + ```powershell + # Replace YOUR_PROJECT_ID with your actual Google Cloud project ID + $env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" + ``` + To make this setting persistent, see [Persisting Environment Variables](#persisting-vars). @@ -257,27 +331,38 @@ To avoid setting environment variables for every terminal session, you can persist them with the following methods: 1. **Add your environment variables to your shell configuration file:** Append - the `export ...` commands to your shell's startup file (e.g., `~/.bashrc`, - `~/.zshrc`, or `~/.profile`) and reload your shell (e.g., - `source ~/.bashrc`). + the environment variable commands to your shell's startup file. + + **macOS/Linux** (for example, `~/.bashrc`, `~/.zshrc`, or `~/.profile`): ```bash - # Example for .bashrc echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc source ~/.bashrc ``` - > **Warning:** Be aware that when you export API keys or service account - > paths in your shell configuration file, any process launched from that - > shell can read them. + **Windows (PowerShell)** (for example, `$PROFILE`): + + ```powershell + Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' + . $PROFILE + ``` + + +> [!WARNING] +> Be aware that when you export API keys or service account +> paths in your shell configuration file, any process launched from that +> shell can read them. 2. **Use a `.env` file:** Create a `.gemini/.env` file in your project directory or home directory. Gemini CLI automatically loads variables from the first `.env` file it finds, searching up from the current directory, - then in `~/.gemini/.env` or `~/.env`. `.gemini/.env` is recommended. + then in your home directory's `.gemini/.env` (for example, `~/.gemini/.env` + or `%USERPROFILE%\.gemini\.env`). Example for user-wide settings: + **macOS/Linux** + ```bash mkdir -p ~/.gemini cat >> ~/.gemini/.env <<'EOF' @@ -286,6 +371,16 @@ persist them with the following methods: EOF ``` + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini" + @" + GOOGLE_CLOUD_PROJECT="your-project-id" + # Add other variables like GEMINI_API_KEY as needed + "@ | Out-File -FilePath "$env:USERPROFILE\.gemini\.env" -Encoding utf8 -Append + ``` + Variables are loaded from the first file found, not merged. ## Running in Google Cloud environments @@ -306,7 +401,7 @@ on this page. [Headless mode](../cli/headless) will use your existing authentication method, if an existing authentication credential is cached. -If you have not already logged in with an authentication credential, you must +If you have not already signed in with an authentication credential, you must configure authentication using environment variables: - [Use Gemini API Key](#gemini-api) diff --git a/docs/get-started/examples.md b/docs/get-started/examples.md index 5d31ddedb8..18ebf865b4 100644 --- a/docs/get-started/examples.md +++ b/docs/get-started/examples.md @@ -4,7 +4,9 @@ Gemini CLI helps you automate common engineering tasks by combining AI reasoning with local system tools. This document provides examples of how to use the CLI for file management, code analysis, and data transformation. -> **Note:** These examples demonstrate potential capabilities. Your actual + +> [!NOTE] +> These examples demonstrate potential capabilities. Your actual > results can vary based on the model used and your project environment. ## Rename your photographs based on content diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index a5eed9ab1d..8e0af1a9ce 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -2,7 +2,9 @@ Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! -> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have + +> [!NOTE] +> Gemini 3.1 Pro Preview is rolling out. To determine whether you have > access to Gemini 3.1, use the `/model` command and select **Manual**. If you > have access, you will see `gemini-3.1-pro-preview`. > @@ -25,7 +27,7 @@ Get started by upgrading Gemini CLI to the latest version: npm install -g @google/gemini-cli@latest ``` -After you’ve confirmed your version is 0.21.1 or later: +If your version is 0.21.1 or later: 1. Run `/model`. 2. Select **Auto (Gemini 3)**. @@ -39,6 +41,12 @@ When you encounter that limit, you’ll be given the option to switch to Gemini 2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage limit resets and Gemini 3 Pro can be used again. + +> [!TIP] +> Looking to upgrade for higher limits? To compare subscription +> options and find the right quota for your needs, see our +> [Plans page](https://geminicli.com/plans/). + Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, you’ll see a message prompting fallback to Gemini 2.5 Flash. @@ -48,7 +56,9 @@ There may be times when the Gemini 3 Pro model is overloaded. When that happens, Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro or fallback to Gemini 2.5 Pro. -> **Note:** The **Keep trying** option uses exponential backoff, in which Gemini + +> [!NOTE] +> The **Keep trying** option uses exponential backoff, in which Gemini > CLI waits longer between each retry, when the system is busy. If the retry > doesn't happen immediately, please wait a few minutes for the request to > process. @@ -105,7 +115,7 @@ then: Restart Gemini CLI and you should have access to Gemini 3. -## Need help? +## Next steps If you need help, we recommend searching for an existing [GitHub issue](https://github.com/google-gemini/gemini-cli/issues). If you diff --git a/docs/get-started/index.md b/docs/get-started/index.md index bc29581d2f..566ac6e9df 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -38,7 +38,7 @@ cases, you can log in with your existing Google account: ``` 2. When asked "How would you like to authenticate for this project?" select **1. - Login with Google**. + Sign in with Google**. 3. Select your Google account. @@ -72,7 +72,7 @@ session's token usage, as well as your overall quota and usage for the supported models. For more information on the `/stats` command and its subcommands, see the -[Command Reference](../../reference/commands.md#stats). +[Command Reference](../reference/commands.md#stats). ## Next steps diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 1acf497659..e56d98d889 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -1,6 +1,6 @@ # Gemini CLI installation, execution, and releases -This document provides an overview of Gemini CLI's sytem requriements, +This document provides an overview of Gemini CLI's system requirements, installation methods, and release types. ## Recommended system specifications @@ -13,7 +13,7 @@ installation methods, and release types. - "Casual" usage: 4GB+ RAM (short sessions, common tasks and edits) - "Power" usage: 16GB+ RAM (long sessions, large codebases, deep context) - **Runtime:** Node.js 20.0.0+ -- **Shell:** Bash or Zsh +- **Shell:** Bash, Zsh, or PowerShell - **Location:** [Gemini Code Assist supported locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas) - **Internet connection required** @@ -70,7 +70,7 @@ gemini ``` For a list of options and additional commands, see the -[CLI cheatsheet](/docs/cli/cli-reference.md). +[CLI cheatsheet](../cli/cli-reference.md). You can also run Gemini CLI using one of the following advanced methods: diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index fd80fc0b40..5158cfc5eb 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -167,6 +167,8 @@ try { Run hook scripts manually with sample JSON input to verify they behave as expected before hooking them up to the CLI. +**macOS/Linux** + ```bash # Create test input cat > test-input.json << 'EOF' @@ -187,7 +189,30 @@ cat test-input.json | .gemini/hooks/my-hook.sh # Check exit code echo "Exit code: $?" +``` +**Windows (PowerShell)** + +```powershell +# Create test input +@" +{ + "session_id": "test-123", + "cwd": "C:\\temp\\test", + "hook_event_name": "BeforeTool", + "tool_name": "write_file", + "tool_input": { + "file_path": "test.txt", + "content": "Test content" + } +} +"@ | Out-File -FilePath test-input.json -Encoding utf8 + +# Test the hook +Get-Content test-input.json | .\.gemini\hooks\my-hook.ps1 + +# Check exit code +Write-Host "Exit code: $LASTEXITCODE" ``` ### Check exit codes @@ -333,7 +358,7 @@ tool_name=$(echo "$input" | jq -r '.tool_name') ### Make scripts executable -Always make hook scripts executable: +Always make hook scripts executable on macOS/Linux: ```bash chmod +x .gemini/hooks/*.sh @@ -341,6 +366,10 @@ chmod +x .gemini/hooks/*.js ``` +**Windows Note**: On Windows, PowerShell scripts (`.ps1`) don't use `chmod`, but +you may need to ensure your execution policy allows them to run (e.g., +`Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`). + ### Version control Commit hooks to share with your team: @@ -420,7 +449,7 @@ When you open a project with hooks defined in `.gemini/settings.json`: Hooks inherit the environment of the Gemini CLI process, which may include sensitive API keys. Gemini CLI provides a -[redaction system](/docs/reference/configuration.md#environment-variable-redaction) +[redaction system](../reference/configuration.md#environment-variable-redaction) that automatically filters variables matching sensitive patterns (e.g., `KEY`, `TOKEN`). @@ -481,6 +510,9 @@ ls -la .gemini/hooks/my-hook.sh chmod +x .gemini/hooks/my-hook.sh ``` +**Windows Note**: On Windows, ensure your execution policy allows running +scripts (e.g., `Get-ExecutionPolicy`). + **Verify script path:** Ensure the path in `settings.json` resolves correctly. ```bash diff --git a/docs/hooks/index.md b/docs/hooks/index.md index b19ceab438..71fdec268f 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -22,11 +22,11 @@ With hooks, you can: ### Getting started -- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating - your first hook with comprehensive examples. -- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security, +- **[Writing hooks guide](../hooks/writing-hooks)**: A tutorial on creating your + first hook with comprehensive examples. +- **[Best practices](../hooks/best-practices)**: Guidelines on security, performance, and debugging. -- **[Hooks reference](/docs/hooks/reference)**: The definitive technical +- **[Hooks reference](../hooks/reference)**: The definitive technical specification of I/O schemas and exit codes. ## Core concepts @@ -143,7 +143,9 @@ Hooks are executed with a sanitized environment. ## Security and risks -> **Warning: Hooks execute arbitrary code with your user privileges.** By + +> [!WARNING] +> Hooks execute arbitrary code with your user privileges. By > configuring hooks, you are allowing scripts to run shell commands on your > machine. @@ -152,8 +154,8 @@ Gemini CLI **fingerprints** project hooks. If a hook's name or command changes (e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will be warned before it executes. -See [Security Considerations](/docs/hooks/best-practices#using-hooks-securely) -for a detailed threat model. +See [Security Considerations](../hooks/best-practices#using-hooks-securely) for +a detailed threat model. ## Managing hooks diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md index 9b7226ac05..5242c3a13d 100644 --- a/docs/hooks/reference.md +++ b/docs/hooks/reference.md @@ -82,10 +82,10 @@ For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is compared against the name of the tool being executed. - **Built-in Tools**: You can match any built-in tool (e.g., `read_file`, - `run_shell_command`). See the [Tools Reference](/docs/tools) for a full list - of available tool names. + `run_shell_command`). See the [Tools Reference](../reference/tools) for a full + list of available tool names. - **MCP Tools**: Tools from MCP servers follow the naming pattern - `mcp____`. + `mcp__`. - **Regex Support**: Matchers support regular expressions (e.g., `matcher: "read_.*"` matches all file reading tools). diff --git a/docs/hooks/writing-hooks.md b/docs/hooks/writing-hooks.md index 33357fccb2..f4f156776f 100644 --- a/docs/hooks/writing-hooks.md +++ b/docs/hooks/writing-hooks.md @@ -28,6 +28,8 @@ Create a directory for hooks and a simple logging script. > This example uses `jq` to parse JSON. If you don't have it installed, you can > perform similar logic using Node.js or Python. +**macOS/Linux** + ```bash mkdir -p .gemini/hooks cat > .gemini/hooks/log-tools.sh << 'EOF' @@ -52,6 +54,28 @@ EOF chmod +x .gemini/hooks/log-tools.sh ``` +**Windows (PowerShell)** + +```powershell +New-Item -ItemType Directory -Force -Path ".gemini\hooks" +@" +# Read hook input from stdin +`$inputJson = `$input | Out-String | ConvertFrom-Json + +# Extract tool name +`$toolName = `$inputJson.tool_name + +# Log to stderr (visible in terminal if hook fails, or captured in logs) +[Console]::Error.WriteLine("Logging tool: `$toolName") + +# Log to file +"[`$(Get-Date -Format 'o')] Tool executed: `$toolName" | Out-File -FilePath ".gemini\tool-log.txt" -Append -Encoding utf8 + +# Return success with empty JSON +"{}" +"@ | Out-File -FilePath ".gemini\hooks\log-tools.ps1" -Encoding utf8 +``` + ## Exit Code Strategies There are two ways to control or block an action in Gemini CLI: @@ -446,5 +470,5 @@ console.error('Consolidating memories for session end...'); While project-level hooks are great for specific repositories, you can share your hooks across multiple projects by packaging them as a -[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md). -This provides version control, easy distribution, and centralized management. +[Gemini CLI extension](../extensions/index.md). This provides version control, +easy distribution, and centralized management. diff --git a/docs/ide-integration/ide-companion-spec.md b/docs/ide-integration/ide-companion-spec.md index 8f17cd896e..7ae22b7eb5 100644 --- a/docs/ide-integration/ide-companion-spec.md +++ b/docs/ide-integration/ide-companion-spec.md @@ -132,9 +132,11 @@ to the CLI whenever the user's context changes. } ``` - **Note:** The `openFiles` list should only include files that exist on disk. - Virtual files (e.g., unsaved files without a path, editor settings pages) - **MUST** be excluded. + +> [!NOTE] +> The `openFiles` list should only include files that exist on disk. +> Virtual files (e.g., unsaved files without a path, editor settings pages) +> **MUST** be excluded. ### How the CLI uses this context diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md index f16be2e730..6ff893a684 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -66,9 +66,11 @@ You can also install the extension directly from a marketplace. Follow your editor's instructions for installing extensions from this registry. -> NOTE: The "Gemini CLI Companion" extension may appear towards the bottom of -> search results. If you don't see it immediately, try scrolling down or sorting -> by "Newly Published". + +> [!NOTE] +> The "Gemini CLI Companion" extension may appear towards the bottom of +> search results. If you don't see it immediately, try scrolling down or +> sorting by "Newly Published". > > After manually installing the extension, you must run `/ide enable` in the CLI > to activate the integration. @@ -103,7 +105,9 @@ IDE, run: If connected, this command will show the IDE it's connected to and a list of recently opened files it is aware of. -> [!NOTE] The file list is limited to 10 recently accessed files within your + +> [!NOTE] +> The file list is limited to 10 recently accessed files within your > workspace and only includes local files on disk.) ### Working with diffs @@ -177,10 +181,18 @@ standalone terminal and want to manually associate it with a specific IDE instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the process ID (PID) of your IDE. +**macOS/Linux** + ```bash export GEMINI_CLI_IDE_PID=12345 ``` +**Windows (PowerShell)** + +```powershell +$env:GEMINI_CLI_IDE_PID=12345 +``` + When this variable is set, Gemini CLI will skip automatic detection and attempt to connect using the provided PID. diff --git a/docs/index.md b/docs/index.md index 3ccaf3b797..af1915bb8f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -108,8 +108,8 @@ Deep technical documentation and API specifications. processes memory from various sources. - **[Policy engine](./reference/policy-engine.md):** Fine-grained execution control. -- **[Tools API](./reference/tools-api.md):** The API for defining and using - tools. +- **[Tools reference](./reference/tools.md):** Information on how tools are + defined, registered, and used. ## Resources diff --git a/docs/issue-and-pr-automation.md b/docs/issue-and-pr-automation.md index 27185de11c..6f27592833 100644 --- a/docs/issue-and-pr-automation.md +++ b/docs/issue-and-pr-automation.md @@ -14,7 +14,9 @@ feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle. -> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project + +> [!NOTE] +> Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. --- @@ -113,7 +115,45 @@ process. ensure every issue is eventually categorized, even if the initial triage fails. -### 5. Release automation +### 5. Automatic unassignment of inactive contributors: `Unassign Inactive Issue Assignees` + +To keep the list of open `help wanted` issues accessible to all contributors, +this workflow automatically removes **external contributors** who have not +opened a linked pull request within **7 days** of being assigned. Maintainers, +org members, and repo collaborators with write access or above are always exempt +and will never be auto-unassigned. + +- **Workflow File**: `.github/workflows/unassign-inactive-assignees.yml` +- **When it runs**: Every day at 09:00 UTC, and can be triggered manually with + an optional `dry_run` mode. +- **What it does**: + 1. Finds every open issue labeled `help wanted` that has at least one + assignee. + 2. Identifies privileged users (team members, repo collaborators with write+ + access, maintainers) and skips them entirely. + 3. For each remaining (external) assignee it reads the issue's timeline to + determine: + - The exact date they were assigned (using `assigned` timeline events). + - Whether they have opened a PR that is already linked/cross-referenced to + the issue. + 4. Each cross-referenced PR is fetched to verify it is **ready for review**: + open and non-draft, or already merged. Draft PRs do not count. + 5. If an assignee has been assigned for **more than 7 days** and no qualifying + PR is found, they are automatically unassigned and a comment is posted + explaining the reason and how to re-claim the issue. + 6. Assignees who have a non-draft, open or merged PR linked to the issue are + **never** unassigned by this workflow. +- **What you should do**: + - **Open a real PR, not a draft**: Within 7 days of being assigned, open a PR + that is ready for review and include `Fixes #` in the + description. Draft PRs do not satisfy the requirement and will not prevent + auto-unassignment. + - **Re-assign if unassigned by mistake**: Comment `/assign` on the issue to + assign yourself again. + - **Unassign yourself** if you can no longer work on the issue by commenting + `/unassign`, so other contributors can pick it up right away. + +### 6. Release automation This workflow handles the process of packaging and publishing new versions of the Gemini CLI. diff --git a/docs/local-development.md b/docs/local-development.md index e194307eae..83520c7506 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -1,25 +1,22 @@ # Local development guide This guide provides instructions for setting up and using local development -features, such as development tracing. +features for Gemini CLI. -## Development tracing +## Tracing -Development traces (dev traces) are OpenTelemetry (OTel) traces that help you -debug your code by instrumenting interesting events like model calls, tool -scheduler, tool calls, etc. +Gemini CLI uses OpenTelemetry (OTel) to record traces that help you debug agent +behavior. Traces instrument key events like model calls, tool scheduler +operations, and tool calls. -Dev traces are verbose and are specifically meant for understanding agent -behavior and debugging issues. They are disabled by default. +Traces provide deep visibility into agent behavior and help you debug complex +issues. They are captured automatically when you enable telemetry. -To enable dev traces, set the `GEMINI_DEV_TRACING=true` environment variable -when running Gemini CLI. +### View traces -### Viewing dev traces +You can view traces using Genkit Developer UI, Jaeger, or Google Cloud. -You can view dev traces using either Jaeger or the Genkit Developer UI. - -#### Using Genkit +#### Use Genkit Genkit provides a web-based UI for viewing traces and other telemetry data. @@ -31,19 +28,15 @@ Genkit provides a web-based UI for viewing traces and other telemetry data. npm run telemetry -- --target=genkit ``` - The script will output the URL for the Genkit Developer UI, for example: + The script will output the URL for the Genkit Developer UI. For example: + `Genkit Developer UI: http://localhost:4000` - ``` - Genkit Developer UI: http://localhost:4000 - ``` +2. **Run Gemini CLI:** -2. **Run Gemini CLI with dev tracing:** - - In a separate terminal, run your Gemini CLI command with the - `GEMINI_DEV_TRACING` environment variable: + In a separate terminal, run your Gemini CLI command: ```bash - GEMINI_DEV_TRACING=true gemini + gemini ``` 3. **View the traces:** @@ -51,29 +44,29 @@ Genkit provides a web-based UI for viewing traces and other telemetry data. Open the Genkit Developer UI URL in your browser and navigate to the **Traces** tab to view the traces. -#### Using Jaeger +#### Use Jaeger -You can view dev traces in the Jaeger UI. To get started, follow these steps: +You can view traces in the Jaeger UI for local development. 1. **Start the telemetry collector:** Run the following command in your terminal to download and start Jaeger and - an OTEL collector: + an OTel collector: ```bash npm run telemetry -- --target=local ``` - This command also configures your workspace for local telemetry and provides - a link to the Jaeger UI (usually `http://localhost:16686`). + This command configures your workspace for local telemetry and provides a + link to the Jaeger UI (usually `http://localhost:16686`). + - **Collector logs:** `~/.gemini/tmp//otel/collector.log` -2. **Run Gemini CLI with dev tracing:** +2. **Run Gemini CLI:** - In a separate terminal, run your Gemini CLI command with the - `GEMINI_DEV_TRACING` environment variable: + In a separate terminal, run your Gemini CLI command: ```bash - GEMINI_DEV_TRACING=true gemini + gemini ``` 3. **View the traces:** @@ -81,44 +74,103 @@ You can view dev traces in the Jaeger UI. To get started, follow these steps: After running your command, open the Jaeger UI link in your browser to view the traces. +#### Use Google Cloud + +You can use an OpenTelemetry collector to forward telemetry data to Google Cloud +Trace for custom processing or routing. + + +> [!WARNING] +> Ensure you complete the +> [Google Cloud telemetry prerequisites](./cli/telemetry.md#prerequisites) +> (Project ID, authentication, IAM roles, and APIs) before using this method. + +1. **Configure `.gemini/settings.json`:** + + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp", + "useCollector": true + } + } + ``` + +2. **Start the telemetry collector:** + + Run the following command to start a local OTel collector that forwards to + Google Cloud: + + ```bash + npm run telemetry -- --target=gcp + ``` + + The script outputs links to view traces, metrics, and logs in the Google + Cloud Console. + - **Collector logs:** `~/.gemini/tmp//otel/collector-gcp.log` + +3. **Run Gemini CLI:** + + In a separate terminal, run your Gemini CLI command: + + ```bash + gemini + ``` + +4. **View logs, metrics, and traces:** + + After sending prompts, view your data in the Google Cloud Console. See the + [telemetry documentation](./cli/telemetry.md#view-google-cloud-telemetry) + for links to Logs, Metrics, and Trace explorers. + For more detailed information on telemetry, see the [telemetry documentation](./cli/telemetry.md). -### Instrumenting code with dev traces +### Instrument code with traces -You can add dev traces to your own code for more detailed instrumentation. This -is useful for debugging and understanding the flow of execution. +You can add traces to your own code for more detailed instrumentation. -Use the `runInDevTraceSpan` function to wrap any section of code in a trace -span. +Adding traces helps you debug and understand the flow of execution. Use the +`runInDevTraceSpan` function to wrap any section of code in a trace span. Here is a basic example: ```typescript import { runInDevTraceSpan } from '@google/gemini-cli-core'; +import { GeminiCliOperation } from '@google/gemini-cli-core/lib/telemetry/constants.js'; -await runInDevTraceSpan({ name: 'my-custom-span' }, async ({ metadata }) => { - // The `metadata` object allows you to record the input and output of the - // operation as well as other attributes. - metadata.input = { key: 'value' }; - // Set custom attributes. - metadata.attributes['gen_ai.request.model'] = 'gemini-4.0-mega'; +await runInDevTraceSpan( + { + operation: GeminiCliOperation.ToolCall, + attributes: { + [GEN_AI_AGENT_NAME]: 'gemini-cli', + }, + }, + async ({ metadata }) => { + // metadata allows you to record the input and output of the + // operation as well as other attributes. + metadata.input = { key: 'value' }; + // Set custom attributes. + metadata.attributes['custom.attribute'] = 'custom.value'; - // Your code to be traced goes here - try { - const output = await somethingRisky(); - metadata.output = output; - return output; - } catch (e) { - metadata.error = e; - throw e; - } -}); + // Your code to be traced goes here. + try { + const output = await somethingRisky(); + metadata.output = output; + return output; + } catch (e) { + metadata.error = e; + throw e; + } + }, +); ``` In this example: -- `name`: The name of the span, which will be displayed in the trace. +- `operation`: The operation type of the span, represented by the + `GeminiCliOperation` enum. - `metadata.input`: (Optional) An object containing the input data for the traced operation. - `metadata.output`: (Optional) An object containing the output data from the diff --git a/docs/redirects.json b/docs/redirects.json index 5183d0d476..598f42cccf 100644 --- a/docs/redirects.json +++ b/docs/redirects.json @@ -8,7 +8,8 @@ "/docs/core/concepts": "/docs", "/docs/core/memport": "/docs/reference/memport", "/docs/core/policy-engine": "/docs/reference/policy-engine", - "/docs/core/tools-api": "/docs/reference/tools-api", + "/docs/core/tools-api": "/docs/reference/tools", + "/docs/reference/tools-api": "/docs/reference/tools", "/docs/faq": "/docs/resources/faq", "/docs/get-started/configuration": "/docs/reference/configuration", "/docs/get-started/configuration-v1": "/docs/reference/configuration", diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 41faf5cb64..26df26f32a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -14,6 +14,31 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** Show version info. Share this information when filing issues. +### `/agents` + +- **Description:** Manage local and remote subagents. +- **Note:** This command is experimental and requires + `experimental.enableAgents: true` in your `settings.json`. +- **Sub-commands:** + - **`list`**: + - **Description:** Lists all discovered agents, including built-in, local, + and remote agents. + - **Usage:** `/agents list` + - **`reload`** (alias: `refresh`): + - **Description:** Rescans agent directories (`~/.gemini/agents` and + `.gemini/agents`) and reloads the registry. + - **Usage:** `/agents reload` + - **`enable`**: + - **Description:** Enables a specific subagent. + - **Usage:** `/agents enable ` + - **`disable`**: + - **Description:** Disables a specific subagent. + - **Usage:** `/agents disable ` + - **`config`**: + - **Description:** Opens a configuration dialog for the specified agent to + adjust its model, temperature, or execution limits. + - **Usage:** `/agents config ` + ### `/auth` - **Description:** Open a dialog that lets you change the authentication method. @@ -28,24 +53,33 @@ Slash commands provide meta-level control over the CLI itself. ### `/chat` -- **Description:** Save and resume conversation history for branching - conversation state interactively, or resuming a previous state from a later - session. +- **Description:** Alias for `/resume`. Both commands now expose the same + session browser action and checkpoint subcommands. +- **Menu layout when typing `/chat` (or `/resume`)**: + - `-- auto --` + - `list` (selecting this opens the auto-saved session browser) + - `-- checkpoints --` + - `list`, `save`, `resume`, `delete`, `share` (manual tagged checkpoints) + - Unique prefixes (for example `/cha` or `/resu`) resolve to the same grouped + menu. - **Sub-commands:** - **`debug`** - **Description:** Export the most recent API request as a JSON payload. - **`delete `** - **Description:** Deletes a saved conversation checkpoint. + - **Equivalent:** `/resume delete ` - **`list`** - - **Description:** Lists available tags for chat state resumption. + - **Description:** Lists available tags for manually saved checkpoints. - **Note:** This command only lists chats saved within the current project. Because chat history is project-scoped, chats saved in other project directories will not be displayed. + - **Equivalent:** `/resume list` - **`resume `** - **Description:** Resumes a conversation from a previous save. - **Note:** You can only resume chats that were saved within the current project. To resume a chat from a different project, you must run the Gemini CLI from that project's directory. + - **Equivalent:** `/resume resume ` - **`save `** - **Description:** Saves the current conversation history. You must add a `` for identifying the conversation state. @@ -60,10 +94,12 @@ Slash commands provide meta-level control over the CLI itself. conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../cli/checkpointing.md). + - **Equivalent:** `/resume save ` - **`share [filename]`** - - **Description** Writes the current conversation to a provided Markdown or + - **Description:** Writes the current conversation to a provided Markdown or JSON file. If no filename is provided, then the CLI will generate one. - - **Usage** `/chat share file.md` or `/chat share file.json`. + - **Usage:** `/chat share file.md` or `/chat share file.json`. + - **Equivalent:** `/resume share [filename]` ### `/clear` @@ -268,8 +304,11 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** Switch to Plan Mode (read-only) and view the current plan if one has been generated. - - **Note:** This feature requires the `experimental.plan` setting to be - enabled in your configuration. + - **Note:** This feature is enabled by default. It can be disabled via the + `experimental.plan` setting in your configuration. +- **Sub-commands:** + - **`copy`**: + - **Description:** Copy the currently approved plan to your clipboard. ### `/policies` @@ -311,10 +350,13 @@ Slash commands provide meta-level control over the CLI itself. ### `/resume` -- **Description:** Browse and resume previous conversation sessions. Opens an - interactive session browser where you can search, filter, and select from - automatically saved conversations. +- **Description:** Browse and resume previous conversation sessions, and manage + manual chat checkpoints. - **Features:** + - **Auto sessions:** Run `/resume` to open the interactive session browser for + automatically saved conversations. + - **Chat checkpoints:** Use checkpoint subcommands directly (`/resume save`, + `/resume resume`, etc.). - **Management:** Delete unwanted sessions directly from the browser - **Resume:** Select any session to resume and continue the conversation - **Search:** Use `/` to search through conversation content across all @@ -329,6 +371,23 @@ Slash commands provide meta-level control over the CLI itself. - **Note:** All conversations are automatically saved as you chat - no manual saving required. See [Session Management](../cli/session-management.md) for complete details. +- **Alias:** `/chat` provides the same behavior and subcommands. +- **Sub-commands:** + - **`list`** + - **Description:** Lists available tags for manual chat checkpoints. + - **`save `** + - **Description:** Saves the current conversation as a tagged checkpoint. + - **`resume `** (alias: `load`) + - **Description:** Loads a previously saved tagged checkpoint. + - **`delete `** + - **Description:** Deletes a tagged checkpoint. + - **`share [filename]`** + - **Description:** Exports the current conversation to Markdown or JSON. + - **`debug`** + - **Description:** Export the most recent API request as JSON payload + (nightly builds). + - **Compatibility alias:** `/resume checkpoints ...` is still accepted for the + same checkpoint commands. ### `/settings` @@ -409,6 +468,12 @@ Slash commands provide meta-level control over the CLI itself. - **`nodesc`** or **`nodescriptions`**: - **Description:** Hide tool descriptions, showing only the tool names. +### `/upgrade` + +- **Description:** Open the Gemini Code Assist upgrade page in your browser. + This lets you upgrade your tier for higher usage limits. +- **Note:** This command is only available when logged in with Google. + ### `/vim` - **Description:** Toggle vim mode on or off. When vim mode is enabled, the diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 62a8fb9651..9b4945cd68 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -25,7 +25,9 @@ overridden by higher numbers): Gemini CLI uses JSON settings files for persistent configuration. There are four locations for these files: -> **Tip:** JSON-aware editors can use autocomplete and validation by pointing to + +> [!TIP] +> JSON-aware editors can use autocomplete and validation by pointing to > the generated schema at `schemas/settings.schema.json` in this repository. > When working outside the repo, reference the hosted schema at > `https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json`. @@ -66,9 +68,9 @@ an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. Additionally, each extension can have its own `.env` file in its directory, which will be loaded automatically. -> **Note for Enterprise Users:** For guidance on deploying and managing Gemini -> CLI in a corporate environment, please see the -> [Enterprise Configuration](../cli/enterprise.md) documentation. +**Note for Enterprise Users:** For guidance on deploying and managing Gemini CLI +in a corporate environment, please see the +[Enterprise Configuration](../cli/enterprise.md) documentation. ### The `.gemini` directory in your project @@ -92,6 +94,13 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `[]` - **Requires restart:** Yes +#### `adminPolicyPaths` + +- **`adminPolicyPaths`** (array): + - **Description:** Additional admin policy files or directories to load. + - **Default:** `[]` + - **Requires restart:** Yes + #### `general` - **`general.preferredEditor`** (string): @@ -105,7 +114,8 @@ their corresponding top-level category object in your `settings.json` file. - **`general.defaultApprovalMode`** (enum): - **Description:** The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is - read-only mode. 'yolo' is not supported yet. + read-only mode. YOLO mode (auto-approve all actions) can only be enabled via + command line (--yolo or --approval-mode=yolo). - **Default:** `"default"` - **Values:** `"default"`, `"auto_edit"`, `"plan"` @@ -146,7 +156,7 @@ their corresponding top-level category object in your `settings.json` file. - **`general.retryFetchErrors`** (boolean): - **Description:** Retry on "exception TypeError: fetch failed sending request" errors. - - **Default:** `false` + - **Default:** `true` - **`general.maxAttempts`** (number): - **Description:** Maximum number of attempts for requests to the main chat @@ -159,12 +169,12 @@ their corresponding top-level category object in your `settings.json` file. - **`general.sessionRetention.enabled`** (boolean): - **Description:** Enable automatic session cleanup - - **Default:** `false` + - **Default:** `true` - **`general.sessionRetention.maxAge`** (string): - **Description:** Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") - - **Default:** `undefined` + - **Default:** `"30d"` - **`general.sessionRetention.maxCount`** (number): - **Description:** Alternative: Maximum number of sessions to keep (most @@ -175,11 +185,6 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Minimum retention period (safety limit, defaults to "1d") - **Default:** `"1d"` -- **`general.sessionRetention.warningAcknowledged`** (boolean): - - **Description:** INTERNAL: Whether the user has acknowledged the session - retention warning - - **Default:** `false` - #### `output` - **`output.format`** (enum): @@ -242,6 +247,11 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Hide helpful tips in the UI - **Default:** `false` +- **`ui.escapePastedAtSymbols`** (boolean): + - **Description:** When enabled, @ symbols in pasted text are escaped to + prevent unintended @path expansion. + - **Default:** `false` + - **`ui.showShortcutsHint`** (boolean): - **Description:** Show the "? for shortcuts" hint above the input. - **Default:** `true` @@ -255,8 +265,18 @@ their corresponding top-level category object in your `settings.json` file. input. - **Default:** `false` +- **`ui.footer.items`** (array): + - **Description:** List of item IDs to display in the footer. Rendered in + order + - **Default:** `undefined` + +- **`ui.footer.showLabels`** (boolean): + - **Description:** Display a second line above the footer items with + descriptive headers (e.g., /model). + - **Default:** `true` + - **`ui.footer.hideCWD`** (boolean): - - **Description:** Hide the current working directory path in the footer. + - **Description:** Hide the current working directory in the footer. - **Default:** `false` - **`ui.footer.hideSandboxStatus`** (boolean): @@ -268,7 +288,7 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`ui.footer.hideContextPercentage`** (boolean): - - **Description:** Hides the context window remaining percentage. + - **Description:** Hides the context window usage percentage. - **Default:** `true` - **`ui.hideFooter`** (boolean): @@ -292,7 +312,7 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`ui.showUserIdentity`** (boolean): - - **Description:** Show the logged-in user's identity (e.g. email) in the UI. + - **Description:** Show the signed-in user's identity (e.g. email) in the UI. - **Default:** `true` - **`ui.useAlternateBuffer`** (boolean): @@ -322,6 +342,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `"tips"` - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` +- **`ui.errorVerbosity`** (enum): + - **Description:** Controls whether recoverable errors are hidden (low) or + fully shown (full). + - **Default:** `"low"` + - **Values:** `"low"`, `"full"` + - **`ui.customWittyPhrases`** (array): - **Description:** Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults. @@ -357,6 +383,15 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +#### `billing` + +- **`billing.overageStrategy`** (enum): + - **Description:** How to handle quota exhaustion when AI credits are + available. 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + - **Default:** `"ask"` + - **Values:** `"ask"`, `"always"`, `"never"` + #### `model` - **`model.name`** (string): @@ -644,6 +679,493 @@ their corresponding top-level category object in your `settings.json` file. used. - **Default:** `[]` +- **`modelConfigs.modelDefinitions`** (object): + - **Description:** Registry of model metadata, including tier, family, and + features. + - **Default:** + + ```json + { + "gemini-3.1-flash-lite-preview": { + "tier": "flash-lite", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, + "gemini-3.1-pro-preview": { + "tier": "pro", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": true, + "multimodalToolUse": true + } + }, + "gemini-3.1-pro-preview-customtools": { + "tier": "pro", + "family": "gemini-3", + "isPreview": true, + "isVisible": false, + "features": { + "thinking": true, + "multimodalToolUse": true + } + }, + "gemini-3-pro-preview": { + "tier": "pro", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": true, + "multimodalToolUse": true + } + }, + "gemini-3-flash-preview": { + "tier": "flash", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, + "gemini-2.5-pro": { + "tier": "pro", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "gemini-2.5-flash": { + "tier": "flash", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "gemini-2.5-flash-lite": { + "tier": "flash-lite", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "auto": { + "tier": "auto", + "isPreview": true, + "isVisible": false, + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "pro": { + "tier": "pro", + "isPreview": false, + "isVisible": false, + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "flash": { + "tier": "flash", + "isPreview": false, + "isVisible": false, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "flash-lite": { + "tier": "flash-lite", + "isPreview": false, + "isVisible": false, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "auto-gemini-3": { + "displayName": "Auto (Gemini 3)", + "tier": "auto", + "isPreview": true, + "isVisible": true, + "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash", + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "auto-gemini-2.5": { + "displayName": "Auto (Gemini 2.5)", + "tier": "auto", + "isPreview": false, + "isVisible": true, + "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash", + "features": { + "thinking": false, + "multimodalToolUse": false + } + } + } + ``` + + - **Requires restart:** Yes + +- **`modelConfigs.modelIdResolutions`** (object): + - **Description:** Rules for resolving requested model names to concrete model + IDs based on context. + - **Default:** + + ```json + { + "gemini-3.1-pro-preview": { + "default": "gemini-3.1-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3.1-pro-preview-customtools": { + "default": "gemini-3.1-pro-preview-customtools", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3-flash-preview": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + } + ] + }, + "gemini-3-pro-preview": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "auto-gemini-3": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "auto": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "pro": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "auto-gemini-2.5": { + "default": "gemini-2.5-pro" + }, + "flash": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + } + ] + }, + "flash-lite": { + "default": "gemini-2.5-flash-lite" + } + } + ``` + + - **Requires restart:** Yes + +- **`modelConfigs.classifierIdResolutions`** (object): + - **Description:** Rules for resolving classifier tiers (flash, pro) to + concrete model IDs. + - **Default:** + + ```json + { + "flash": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"] + }, + "target": "gemini-2.5-flash" + }, + { + "condition": { + "requestedModels": ["auto-gemini-3", "gemini-3-pro-preview"] + }, + "target": "gemini-3-flash-preview" + } + ] + }, + "pro": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"] + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + } + } + ``` + + - **Requires restart:** Yes + +- **`modelConfigs.modelChains`** (object): + - **Description:** Availability policy chains defining fallback behavior for + models. + - **Default:** + + ```json + { + "preview": [ + { + "model": "gemini-3-pro-preview", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-3-flash-preview", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "default": [ + { + "model": "gemini-2.5-pro", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "lite": [ + { + "model": "gemini-2.5-flash-lite", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-pro", + "isLastResort": true, + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ] + } + ``` + + - **Requires restart:** Yes + #### `agents` - **`agents.overrides`** (object): @@ -673,6 +1195,32 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `undefined` - **Requires restart:** Yes +- **`agents.browser.allowedDomains`** (array): + - **Description:** A list of allowed domains for the browser agent (e.g., + ["github.com", "*.google.com"]). + - **Default:** + + ```json + ["github.com", "*.google.com", "localhost"] + ``` + + - **Requires restart:** Yes + +- **`agents.browser.disableUserInput`** (boolean): + - **Description:** Disable user input on browser window during automation. + - **Default:** `true` + +- **`agents.browser.confirmSensitiveActions`** (boolean): + - **Description:** Require manual confirmation for sensitive browser actions + (e.g., fill_form, evaluate_script). + - **Default:** `false` + - **Requires restart:** Yes + +- **`agents.browser.blockFileUploads`** (boolean): + - **Description:** Hard-block file upload requests from the browser agent. + - **Default:** `false` + - **Requires restart:** Yes + #### `context` - **`context.fileName`** (string | string[]): @@ -699,7 +1247,7 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `[]` - **`context.loadMemoryFromIncludeDirectories`** (boolean): - - **Description:** Controls how /memory refresh loads GEMINI.md files. When + - **Description:** Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. - **Default:** `false` @@ -735,12 +1283,25 @@ their corresponding top-level category object in your `settings.json` file. #### `tools` -- **`tools.sandbox`** (boolean | string): - - **Description:** Sandbox execution environment. Set to a boolean to enable - or disable the sandbox, or provide a string path to a sandbox profile. +- **`tools.sandbox`** (string): + - **Description:** Legacy full-process sandbox execution environment. Set to a + boolean to enable or disable the sandbox, provide a string path to a sandbox + profile, or specify an explicit sandbox command (e.g., "docker", "podman", + "lxc", "windows-native"). - **Default:** `undefined` - **Requires restart:** Yes +- **`tools.sandboxAllowedPaths`** (array): + - **Description:** List of additional paths that the sandbox is allowed to + access. + - **Default:** `[]` + - **Requires restart:** Yes + +- **`tools.sandboxNetworkAccess`** (boolean): + - **Description:** Whether the sandbox is allowed to access the network. + - **Default:** `false` + - **Requires restart:** Yes + - **`tools.shell.enableInteractiveShell`** (boolean): - **Description:** Use node-pty for an interactive shell experience. Fallback to child_process still applies. @@ -841,16 +1402,32 @@ their corresponding top-level category object in your `settings.json` file. #### `security` +- **`security.toolSandboxing`** (boolean): + - **Description:** Experimental tool-level sandboxing (implementation in + progress). + - **Default:** `false` + - **`security.disableYoloMode`** (boolean): - **Description:** Disable YOLO mode, even if enabled by a flag. - **Default:** `false` - **Requires restart:** Yes +- **`security.disableAlwaysAllow`** (boolean): + - **Description:** Disable "Always allow" options in tool confirmation + dialogs. + - **Default:** `false` + - **Requires restart:** Yes + - **`security.enablePermanentToolApproval`** (boolean): - **Description:** Enable the "Allow for all future sessions" option in tool confirmation dialogs. - **Default:** `false` +- **`security.autoAddToPolicyByDefault`** (boolean): + - **Description:** When enabled, the "Allow for all future sessions" option + becomes the default choice for low-risk tools in trusted workspaces. + - **Default:** `false` + - **`security.blockGitExtensions`** (boolean): - **Description:** Blocks installing and loading extensions from Git. - **Default:** `false` @@ -957,8 +1534,12 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`experimental.enableAgents`** (boolean): - - **Description:** Enable local and remote subagents. Warning: Experimental - feature, uses YOLO mode for subagents + - **Description:** Enable local and remote subagents. + - **Default:** `true` + - **Requires restart:** Yes + +- **`experimental.worktrees`** (boolean): + - **Description:** Enable automated Git worktree management for parallel work. - **Default:** `false` - **Requires restart:** Yes @@ -977,6 +1558,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`experimental.extensionRegistryURI`** (string): + - **Description:** The URI (web URL or local file path) of the extension + registry. + - **Default:** `"https://geminicli.com/extensions.json"` + - **Requires restart:** Yes + - **`experimental.extensionReloading`** (boolean): - **Description:** Enables extension loading/unloading within the CLI session. - **Default:** `false` @@ -984,7 +1571,7 @@ their corresponding top-level category object in your `settings.json` file. - **`experimental.jitContext`** (boolean): - **Description:** Enable Just-In-Time (JIT) context loading. - - **Default:** `false` + - **Default:** `true` - **Requires restart:** Yes - **`experimental.useOSC52Paste`** (boolean): @@ -1000,7 +1587,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`experimental.plan`** (boolean): - - **Description:** Enable planning features (Plan Mode and tools). + - **Description:** Enable Plan Mode. + - **Default:** `true` + - **Requires restart:** Yes + +- **`experimental.taskTracker`** (boolean): + - **Description:** Enable task tracker tools. - **Default:** `false` - **Requires restart:** Yes @@ -1014,6 +1606,41 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`experimental.dynamicModelConfiguration`** (boolean): + - **Description:** Enable dynamic model configuration (definitions, + resolutions, and chains) via settings. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.enabled`** (boolean): + - **Description:** Enable the Gemma Model Router (experimental). Requires a + local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.classifier.host`** (string): + - **Description:** The host of the classifier. + - **Default:** `"http://localhost:9379"` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.classifier.model`** (string): + - **Description:** The model to use for the classifier. Only tested on + `gemma3-1b-gpu-custom`. + - **Default:** `"gemma3-1b-gpu-custom"` + - **Requires restart:** Yes + +- **`experimental.memoryManager`** (boolean): + - **Description:** Replace the built-in save_memory tool with a memory manager + subagent that supports adding, removing, de-duplicating, and organizing + memories. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.topicUpdateNarration`** (boolean): + - **Description:** Enable the experimental Topic & Update communication model + for reduced chattiness and structured progress reporting. + - **Default:** `false` + #### `skills` - **`skills.enabled`** (boolean): @@ -1103,7 +1730,8 @@ their corresponding top-level category object in your `settings.json` file. #### `admin` - **`admin.secureModeEnabled`** (boolean): - - **Description:** If true, disallows yolo mode from being used. + - **Description:** If true, disallows YOLO mode and "Always allow" options + from being used. - **Default:** `false` - **`admin.extensions.enabled`** (boolean): @@ -1116,7 +1744,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **`admin.mcp.config`** (object): - - **Description:** Admin-configured MCP servers. + - **Description:** Admin-configured MCP servers (allowlist). + - **Default:** `{}` + +- **`admin.mcp.requiredConfig`** (object): + - **Description:** Admin-required MCP servers that are always injected. - **Default:** `{}` - **`admin.skills.enabled`** (boolean): @@ -1128,13 +1760,22 @@ their corresponding top-level category object in your `settings.json` file. Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Gemini CLI attempts to connect to each -configured MCP server to discover available tools. If multiple MCP servers -expose a tool with the same name, the tool names will be prefixed with the -server alias you defined in the configuration (e.g., -`serverAlias__actualToolName`) to avoid conflicts. Note that the system might -strip certain schema properties from MCP tool definitions for compatibility. At -least one of `command`, `url`, or `httpUrl` must be provided. If multiple are -specified, the order of precedence is `httpUrl`, then `url`, then `command`. +configured MCP server to discover available tools. Every discovered tool is +prepended with the `mcp_` prefix and its server alias to form a fully qualified +name (FQN) (e.g., `mcp_serverAlias_actualToolName`) to avoid conflicts. Note +that the system might strip certain schema properties from MCP tool definitions +for compatibility. At least one of `command`, `url`, or `httpUrl` must be +provided. If multiple are specified, the order of precedence is `httpUrl`, then +`url`, then `command`. + + +> [!WARNING] +> Avoid using underscores (`_`) in your server aliases (e.g., use +> `my-server` instead of `my_server`). The underlying policy engine parses Fully +> Qualified Names (`mcp_server_tool`) using the first underscore after the +> `mcp_` prefix. An underscore in your server alias will cause the parser to +> misidentify the server name, which can cause security policies to fail +> silently. - **`mcpServers.`** (object): The server parameters for the named server. @@ -1300,7 +1941,8 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`GEMINI_MODEL`**: - Specifies the default Gemini model to use. - Overrides the hardcoded default - - Example: `export GEMINI_MODEL="gemini-3-flash-preview"` + - Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell: + `$env:GEMINI_MODEL="gemini-3-flash-preview"`) - **`GEMINI_CLI_IDE_PID`**: - Manually specifies the PID of the IDE process to use for integration. This is useful when running Gemini CLI in a standalone terminal while still @@ -1312,12 +1954,21 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - By default, this is the user's system home directory. The CLI will create a `.gemini` folder inside this directory. - Useful for shared compute environments or keeping CLI state isolated. - - Example: `export GEMINI_CLI_HOME="/path/to/user/config"` + - Example: `export GEMINI_CLI_HOME="/path/to/user/config"` (Windows + PowerShell: `$env:GEMINI_CLI_HOME="C:\path\to\user\config"`) +- **`GEMINI_CLI_SURFACE`**: + - Specifies a custom label to include in the `User-Agent` header for API + traffic reporting. + - This is useful for tracking specific internal tools or distribution + channels. + - Example: `export GEMINI_CLI_SURFACE="my-custom-tool"` (Windows PowerShell: + `$env:GEMINI_CLI_SURFACE="my-custom-tool"`) - **`GOOGLE_API_KEY`**: - Your Google Cloud API key. - Required for using Vertex AI in express mode. - Ensure you have the necessary permissions. - - Example: `export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"`. + - Example: `export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"` (Windows PowerShell: + `$env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"`). - **`GOOGLE_CLOUD_PROJECT`**: - Your Google Cloud Project ID. - Required for using Code Assist or Vertex AI. @@ -1328,18 +1979,23 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. you have `GOOGLE_CLOUD_PROJECT` set in your global environment in Cloud Shell, it will be overridden by this default. To use a different project in Cloud Shell, you must define `GOOGLE_CLOUD_PROJECT` in a `.env` file. - - Example: `export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`. + - Example: `export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows + PowerShell: `$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`). - **`GOOGLE_APPLICATION_CREDENTIALS`** (string): - **Description:** The path to your Google Application Credentials JSON file. - **Example:** `export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/credentials.json"` + (Windows PowerShell: + `$env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\credentials.json"`) - **`GOOGLE_GENAI_API_VERSION`**: - Specifies the API version to use for Gemini API requests. - When set, overrides the default API version used by the SDK. - - Example: `export GOOGLE_GENAI_API_VERSION="v1"` + - Example: `export GOOGLE_GENAI_API_VERSION="v1"` (Windows PowerShell: + `$env:GOOGLE_GENAI_API_VERSION="v1"`) - **`OTLP_GOOGLE_CLOUD_PROJECT`**: - Your Google Cloud Project ID for Telemetry in Google Cloud - - Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`. + - Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows + PowerShell: `$env:OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`). - **`GEMINI_TELEMETRY_ENABLED`**: - Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. @@ -1367,7 +2023,8 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`GOOGLE_CLOUD_LOCATION`**: - Your Google Cloud Project Location (e.g., us-central1). - Required for using Vertex AI in non-express mode. - - Example: `export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"`. + - Example: `export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"` (Windows + PowerShell: `$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"`). - **`GEMINI_SANDBOX`**: - Alternative to the `sandbox` setting in `settings.json`. - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. @@ -1660,7 +2317,7 @@ conventions and context. loaded, allowing you to verify the hierarchy and content being used by the AI. - See the [Commands documentation](./commands.md#memory) for full details on - the `/memory` command and its sub-commands (`show` and `refresh`). + the `/memory` command and its sub-commands (`show` and `reload`). By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 1402422c6b..2ca7a6bb39 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -8,122 +8,201 @@ available combinations. #### Basic Controls -| Action | Keys | -| --------------------------------------------------------------- | --------------------- | -| Confirm the current selection or choice. | `Enter` | -| Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl + [` | -| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` | -| Exit the CLI when the input buffer is empty. | `Ctrl + D` | +| Command | Action | Keys | +| --------------- | --------------------------------------------------------------- | ------------------- | +| `basic.confirm` | Confirm the current selection or choice. | `Enter` | +| `basic.cancel` | Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl+[` | +| `basic.quit` | Cancel the current request or quit the CLI when input is empty. | `Ctrl+C` | +| `basic.exit` | Exit the CLI when the input buffer is empty. | `Ctrl+D` | #### Cursor Movement -| Action | Keys | -| ------------------------------------------- | ------------------------------------------------------------ | -| Move the cursor to the start of the line. | `Ctrl + A`
`Home (no Shift, Ctrl)` | -| Move the cursor to the end of the line. | `Ctrl + E`
`End (no Shift, Ctrl)` | -| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` | -| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` | -| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` | -| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + F` | -| Move the cursor one word to the left. | `Ctrl + Left Arrow`
`Alt + Left Arrow`
`Alt + B` | -| Move the cursor one word to the right. | `Ctrl + Right Arrow`
`Alt + Right Arrow`
`Alt + F` | +| Command | Action | Keys | +| ------------------ | ------------------------------------------- | ------------------------------------------ | +| `cursor.home` | Move the cursor to the start of the line. | `Ctrl+A`
`Home` | +| `cursor.end` | Move the cursor to the end of the line. | `Ctrl+E`
`End` | +| `cursor.up` | Move the cursor up one line. | `Up` | +| `cursor.down` | Move the cursor down one line. | `Down` | +| `cursor.left` | Move the cursor one character to the left. | `Left` | +| `cursor.right` | Move the cursor one character to the right. | `Right`
`Ctrl+F` | +| `cursor.wordLeft` | Move the cursor one word to the left. | `Ctrl+Left`
`Alt+Left`
`Alt+B` | +| `cursor.wordRight` | Move the cursor one word to the right. | `Ctrl+Right`
`Alt+Right`
`Alt+F` | #### Editing -| Action | Keys | -| ------------------------------------------------ | ---------------------------------------------------------------- | -| Delete from the cursor to the end of the line. | `Ctrl + K` | -| Delete from the cursor to the start of the line. | `Ctrl + U` | -| Clear all text in the input field. | `Ctrl + C` | -| Delete the previous word. | `Ctrl + Backspace`
`Alt + Backspace`
`Ctrl + W` | -| Delete the next word. | `Ctrl + Delete`
`Alt + Delete`
`Alt + D` | -| Delete the character to the left. | `Backspace`
`Ctrl + H` | -| Delete the character to the right. | `Delete`
`Ctrl + D` | -| Undo the most recent text edit. | `Cmd + Z (no Shift)`
`Alt + Z (no Shift)` | -| Redo the most recent undone text edit. | `Shift + Ctrl + Z`
`Shift + Cmd + Z`
`Shift + Alt + Z` | +| Command | Action | Keys | +| ---------------------- | ------------------------------------------------ | -------------------------------------------------------- | +| `edit.deleteRightAll` | Delete from the cursor to the end of the line. | `Ctrl+K` | +| `edit.deleteLeftAll` | Delete from the cursor to the start of the line. | `Ctrl+U` | +| `edit.clear` | Clear all text in the input field. | `Ctrl+C` | +| `edit.deleteWordLeft` | Delete the previous word. | `Ctrl+Backspace`
`Alt+Backspace`
`Ctrl+W` | +| `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`
`Alt+Delete`
`Alt+D` | +| `edit.deleteLeft` | Delete the character to the left. | `Backspace`
`Ctrl+H` | +| `edit.deleteRight` | Delete the character to the right. | `Delete`
`Ctrl+D` | +| `edit.undo` | Undo the most recent text edit. | `Cmd/Win+Z`
`Alt+Z` | +| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` | #### Scrolling -| Action | Keys | -| ------------------------ | --------------------------------- | -| Scroll content up. | `Shift + Up Arrow` | -| Scroll content down. | `Shift + Down Arrow` | -| Scroll to the top. | `Ctrl + Home`
`Shift + Home` | -| Scroll to the bottom. | `Ctrl + End`
`Shift + End` | -| Scroll up by one page. | `Page Up` | -| Scroll down by one page. | `Page Down` | +| Command | Action | Keys | +| ----------------- | ------------------------ | ----------------------------- | +| `scroll.up` | Scroll content up. | `Shift+Up` | +| `scroll.down` | Scroll content down. | `Shift+Down` | +| `scroll.home` | Scroll to the top. | `Ctrl+Home`
`Shift+Home` | +| `scroll.end` | Scroll to the bottom. | `Ctrl+End`
`Shift+End` | +| `scroll.pageUp` | Scroll up by one page. | `Page Up` | +| `scroll.pageDown` | Scroll down by one page. | `Page Down` | #### History & Search -| Action | Keys | -| -------------------------------------------- | --------------------- | -| Show the previous entry in history. | `Ctrl + P (no Shift)` | -| Show the next entry in history. | `Ctrl + N (no Shift)` | -| Start reverse search through history. | `Ctrl + R` | -| Submit the selected reverse-search match. | `Enter (no Ctrl)` | -| Accept a suggestion while reverse searching. | `Tab (no Shift)` | -| Browse and rewind previous interactions. | `Double Esc` | +| Command | Action | Keys | +| ----------------------- | -------------------------------------------- | -------- | +| `history.previous` | Show the previous entry in history. | `Ctrl+P` | +| `history.next` | Show the next entry in history. | `Ctrl+N` | +| `history.search.start` | Start reverse search through history. | `Ctrl+R` | +| `history.search.submit` | Submit the selected reverse-search match. | `Enter` | +| `history.search.accept` | Accept a suggestion while reverse searching. | `Tab` | #### Navigation -| Action | Keys | -| -------------------------------------------------- | ------------------------------------------- | -| Move selection up in lists. | `Up Arrow (no Shift)` | -| Move selection down in lists. | `Down Arrow (no Shift)` | -| Move up within dialog options. | `Up Arrow (no Shift)`
`K (no Shift)` | -| Move down within dialog options. | `Down Arrow (no Shift)`
`J (no Shift)` | -| Move to the next item or question in a dialog. | `Tab (no Shift)` | -| Move to the previous item or question in a dialog. | `Shift + Tab` | +| Command | Action | Keys | +| --------------------- | -------------------------------------------------- | --------------- | +| `nav.up` | Move selection up in lists. | `Up` | +| `nav.down` | Move selection down in lists. | `Down` | +| `nav.dialog.up` | Move up within dialog options. | `Up`
`K` | +| `nav.dialog.down` | Move down within dialog options. | `Down`
`J` | +| `nav.dialog.next` | Move to the next item or question in a dialog. | `Tab` | +| `nav.dialog.previous` | Move to the previous item or question in a dialog. | `Shift+Tab` | #### Suggestions & Completions -| Action | Keys | -| --------------------------------------- | -------------------------------------------------- | -| Accept the inline suggestion. | `Tab (no Shift)`
`Enter (no Ctrl)` | -| Move to the previous completion option. | `Up Arrow (no Shift)`
`Ctrl + P (no Shift)` | -| Move to the next completion option. | `Down Arrow (no Shift)`
`Ctrl + N (no Shift)` | -| Expand an inline suggestion. | `Right Arrow` | -| Collapse an inline suggestion. | `Left Arrow` | +| Command | Action | Keys | +| ----------------------- | --------------------------------------- | -------------------- | +| `suggest.accept` | Accept the inline suggestion. | `Tab`
`Enter` | +| `suggest.focusPrevious` | Move to the previous completion option. | `Up`
`Ctrl+P` | +| `suggest.focusNext` | Move to the next completion option. | `Down`
`Ctrl+N` | +| `suggest.expand` | Expand an inline suggestion. | `Right` | +| `suggest.collapse` | Collapse an inline suggestion. | `Left` | #### Text Input -| Action | Keys | -| ---------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` | -| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Alt + Enter`
`Shift + Enter`
`Ctrl + J` | -| Open the current prompt in an external editor. | `Ctrl + X` | -| Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | +| Command | Action | Keys | +| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `input.submit` | Submit the current prompt. | `Enter` | +| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | +| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` | +| `input.paste` | Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | #### App Controls -| Action | Keys | -| -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| Toggle detailed error information. | `F12` | -| Toggle the full TODO list. | `Ctrl + T` | -| Show IDE context details. | `Ctrl + G` | -| Toggle Markdown rendering. | `Alt + M` | -| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` | -| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | -| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift + Tab` | -| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` | -| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` | -| Toggle current background shell visibility. | `Ctrl + B` | -| Toggle background shell list. | `Ctrl + L` | -| Kill the active background shell. | `Ctrl + K` | -| Confirm selection in background shell list. | `Enter` | -| Dismiss background shell list. | `Esc` | -| Move focus from background shell to Gemini. | `Shift + Tab` | -| Move focus from background shell list to Gemini. | `Tab (no Shift)` | -| Show warning when trying to move focus away from background shell. | `Tab (no Shift)` | -| Show warning when trying to move focus away from shell input. | `Tab (no Shift)` | -| Move focus from Gemini to the active shell. | `Tab (no Shift)` | -| Move focus from the shell back to Gemini. | `Shift + Tab` | -| Clear the terminal screen and redraw the UI. | `Ctrl + L` | -| Restart the application. | `R` | -| Suspend the CLI and move it to the background. | `Ctrl + Z` | +| Command | Action | Keys | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `app.showErrorDetails` | Toggle detailed error information. | `F12` | +| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` | +| `app.showIdeContextDetail` | Show IDE context details. | `Ctrl+G` | +| `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` | +| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `Ctrl+S` | +| `app.toggleYolo` | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` | +| `app.cycleApprovalMode` | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` | +| `app.showMoreLines` | Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` | +| `app.expandPaste` | Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl+O` | +| `app.focusShellInput` | Move focus from Gemini to the active shell. | `Tab` | +| `app.unfocusShellInput` | Move focus from the shell back to Gemini. | `Shift+Tab` | +| `app.clearScreen` | Clear the terminal screen and redraw the UI. | `Ctrl+L` | +| `app.restart` | Restart the application. | `R`
`Shift+R` | +| `app.suspend` | Suspend the CLI and move it to the background. | `Ctrl+Z` | +| `app.showShellUnfocusWarning` | Show warning when trying to move focus away from shell input. | `Tab` | + +#### Background Shell Controls + +| Command | Action | Keys | +| --------------------------- | ------------------------------------------------------------------ | ----------- | +| `background.escape` | Dismiss background shell list. | `Esc` | +| `background.select` | Confirm selection in background shell list. | `Enter` | +| `background.toggle` | Toggle current background shell visibility. | `Ctrl+B` | +| `background.toggleList` | Toggle background shell list. | `Ctrl+L` | +| `background.kill` | Kill the active background shell. | `Ctrl+K` | +| `background.unfocus` | Move focus from background shell to Gemini. | `Shift+Tab` | +| `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` | +| `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` | +## Customizing Keybindings + +You can add alternative keybindings or remove default keybindings by creating a +`keybindings.json` file in your home gemini directory (typically +`~/.gemini/keybindings.json`). + +### Configuration Format + +The configuration uses a JSON array of objects, similar to VS Code's keybinding +schema. Each object must specify a `command` from the reference tables above and +a `key` combination. + +```json +[ + { + "command": "edit.clear", + "key": "cmd+l" + }, + { + // prefix "-" to unbind a key + "command": "-app.toggleYolo", + "key": "ctrl+y" + }, + { + "command": "input.submit", + "key": "ctrl+y" + }, + { + // multiple modifiers + "command": "cursor.right", + "key": "shift+alt+a" + }, + { + // Some mac keyboards send "Å" instead of "shift+option+a" + "command": "cursor.right", + "key": "Å" + }, + { + // some base keys have special multi-char names + "command": "cursor.right", + "key": "shift+pageup" + } +] +``` + +- **Unbinding** To remove an existing or default keybinding, prefix a minus sign + (`-`) to the `command` name. +- **No Auto-unbinding** The same key can be bound to multiple commands in + different contexts at the same time. Therefore, creating a binding does not + automatically unbind the key from other commands. +- **Explicit Modifiers**: Key matching is explicit. For example, a binding for + `ctrl+f` will only trigger on exactly `ctrl+f`, not `ctrl+shift+f` or + `alt+ctrl+f`. +- **Literal Characters**: Terminals often translate complex key combinations + (especially on macOS with the `Option` key) into special characters, losing + modifier and keystroke information along the way. For example,`shift+5` might + be sent as `%`. In these cases, you must bind to the literal character `%` as + bindings to `shift+5` will never fire. To see precisely what is being sent, + enable `Debug Keystroke Logging` and hit f12 to open the debug log console. +- **Key Modifiers**: The supported key modifiers are: + - `ctrl` + - `shift`, + - `alt` (synonyms: `opt`, `option`) + - `cmd` (synonym: `meta`) +- **Base Key**: The base key can be any single unicode code point or any of the + following special keys: + - **Navigation**: `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, + `pagedown` + - **Actions**: `enter`, `escape`, `tab`, `space`, `backspace`, `delete`, + `clear`, `insert`, `printscreen` + - **Toggles**: `capslock`, `numlock`, `scrolllock`, `pausebreak` + - **Function Keys**: `f1` through `f35` + - **Numpad**: `numpad0` through `numpad9`, `numpad_add`, `numpad_subtract`, + `numpad_multiply`, `numpad_divide`, `numpad_decimal`, `numpad_separator` + ## Additional context-specific shortcuts - `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your @@ -150,5 +229,18 @@ available combinations. the numbered radio option and confirm when the full number is entered. - `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`) inline when the cursor is over the placeholder. +- `Ctrl + X` (while a plan is presented): Open the plan in an external editor to + [collaboratively edit or comment](../cli/plan-mode.md#collaborative-plan-editing) + on the implementation strategy. - `Double-click` on a paste placeholder (alternate buffer mode only): Expand to view full content inline. Double-click again to collapse. + +## Limitations + +- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal): + - `shift+enter` is only supported in version 1.25 and higher. + - `shift+tab` + [is not supported](https://github.com/google-gemini/gemini-cli/issues/20314) + on Node 20 and earlier versions of Node 22. +- On macOS's [Terminal](): + - `shift+enter` is not supported. diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index a123634581..456c8a9dc8 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -10,9 +10,19 @@ confirmation. To create your first policy: 1. **Create the policy directory** if it doesn't exist: + + **macOS/Linux** + ```bash mkdir -p ~/.gemini/policies ``` + + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\policies" + ``` + 2. **Create a new policy file** (e.g., `~/.gemini/policies/my-rules.toml`). You can use any filename ending in `.toml`; all such files in this directory will be loaded and combined: @@ -50,7 +60,7 @@ command. ```toml [[rule]] toolName = "run_shell_command" -commandPrefix = "git " +commandPrefix = "git" decision = "ask_user" priority = 100 ``` @@ -66,9 +76,13 @@ The `toolName` in the rule must match the name of the tool being called. - **Wildcards**: You can use wildcards to match multiple tools. - `*`: Matches **any tool** (built-in or MCP). - - `server__*`: Matches any tool from a specific MCP server. - - `*__toolName`: Matches a specific tool name across **all** MCP servers. - - `*__*`: Matches **any tool from any MCP server**. + - `mcp_server_*`: Matches any tool from a specific MCP server. + - `mcp_*_toolName`: Matches a specific tool name across **all** MCP servers. + - `mcp_*`: Matches **any tool from any MCP server**. + +> **Recommendation:** While FQN wildcards are supported, the recommended +> approach for MCP tools is to use the `mcpName` field in your TOML rules. See +> [Special syntax for MCP tools](#special-syntax-for-mcp-tools). #### Arguments pattern @@ -76,15 +90,35 @@ If `argsPattern` is specified, the tool's arguments are converted to a stable JSON string, which is then tested against the provided regular expression. If the arguments don't match the pattern, the rule does not apply. +#### Execution environment + +If `interactive` is specified, the rule will only apply if the CLI's execution +environment matches the specified boolean value: + +- `true`: The rule applies only in interactive mode. +- `false`: The rule applies only in non-interactive (headless) mode. + +If omitted, the rule applies to both interactive and non-interactive +environments. + ### Decisions There are three possible decisions a rule can enforce: - `allow`: The tool call is executed automatically without user interaction. -- `deny`: The tool call is blocked and is not executed. +- `deny`: The tool call is blocked and is not executed. For global rules (those + without an `argsPattern`), tools that are denied are **completely excluded + from the model's memory**. This means the model will not even see the tool as + an option, which is more secure and saves context window space. - `ask_user`: The user is prompted to approve or deny the tool call. (In non-interactive mode, this is treated as `deny`.) + +> [!NOTE] +> The `deny` decision is the recommended way to exclude tools. The +> legacy `tools.exclude` setting in `settings.json` is deprecated in favor of +> policy rules with a `deny` decision. + ### Priority system and tiers The policy engine uses a sophisticated priority system to resolve conflicts when @@ -97,9 +131,10 @@ has a designated number that forms the base of the final priority calculation. | Tier | Base | Description | | :-------- | :--- | :------------------------------------------------------------------------- | | Default | 1 | Built-in policies that ship with the Gemini CLI. | -| Workspace | 2 | Policies defined in the current workspace's configuration directory. | -| User | 3 | Custom policies defined by the user. | -| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | +| Extension | 2 | Policies defined in extensions. | +| Workspace | 3 | Policies defined in the current workspace's configuration directory. | +| User | 4 | Custom policies defined by the user. | +| Admin | 5 | Policies managed by an administrator (e.g., in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: @@ -132,8 +167,8 @@ always active. confirmation. - `autoEdit`: Optimized for automated code editing; some write tools may be auto-approved. -- `plan`: A strict, read-only mode for research and design. See [Customizing - Plan Mode Policies]. +- `plan`: A strict, read-only mode for research and design. See + [Customizing Plan Mode Policies](../cli/plan-mode.md#customizing-policies). - `yolo`: A mode where all tools are auto-approved (use with extreme caution). ## Rule matching @@ -146,8 +181,8 @@ A rule matches a tool call if all of its conditions are met: 1. **Tool name**: The `toolName` in the rule must match the name of the tool being called. - - **Wildcards**: You can use wildcards like `*`, `server__*`, or - `*__toolName` to match multiple tools. See [Tool Name](#tool-name) for + - **Wildcards**: You can use wildcards like `*`, `mcp_server_*`, or + `mcp_*_toolName` to match multiple tools. See [Tool Name](#tool-name) for details. 2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments are converted to a stable JSON string, which is then tested against the @@ -169,9 +204,13 @@ User, and (if configured) Admin directories. #### System-wide policies (Admin) -Administrators can enforce system-wide policies (Tier 3) that override all user -and default settings. These policies must be placed in specific, secure -directories: +Administrators can enforce system-wide policies (Tier 4) that override all user +and default settings. These policies can be loaded from standard system +locations or supplemental paths. + +##### Standard Locations + +These are the default paths the CLI searches for admin policies: | OS | Policy Directory Path | | :---------- | :------------------------------------------------ | @@ -179,18 +218,40 @@ directories: | **macOS** | `/Library/Application Support/GeminiCli/policies` | | **Windows** | `C:\ProgramData\gemini-cli\policies` | -**Security Requirements:** +##### Supplemental Admin Policies -To prevent privilege escalation, the CLI enforces strict security checks on -admin directories. If checks fail, system policies are **ignored**. +Administrators can also specify supplemental policy paths using: + +- The `--admin-policy` command-line flag. +- The `adminPolicyPaths` setting in a system settings file. + +These supplemental policies are assigned the same **Admin** tier (Base 4) as +policies in standard locations. + +**Security Guard**: Supplemental admin policies are **ignored** if any `.toml` +policy files are found in the standard system location. This prevents flag-based +overrides when a central system policy has already been established. + +#### Security Requirements + +To prevent privilege escalation, the CLI enforces strict security checks on the +**standard system policy directory**. If checks fail, the policies in that +directory are **ignored**. - **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group or others (e.g., `chmod 755`). - **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`) - must NOT have `Write`, `Modify`, or `Full Control` permissions. _Tip: If you - see a security warning, use the folder properties to remove write permissions - for non-admin groups. You may need to "Disable inheritance" in Advanced - Security Settings._ + must NOT have `Write`, `Modify`, or `Full Control` permissions. If you see a + security warning, use the folder properties to remove write permissions for + non-admin groups. You may need to "Disable inheritance" in Advanced Security + Settings. + + +> [!NOTE] +> Supplemental admin policies (provided via `--admin-policy` or +> `adminPolicyPaths` settings) are **NOT** subject to these strict ownership +> checks, as they are explicitly provided by the user or administrator in their +> current execution context. ### TOML rule schema @@ -201,8 +262,12 @@ Here is a breakdown of the fields available in a TOML policy rule: # A unique name for the tool, or an array of names. toolName = "run_shell_command" +# (Optional) The name of a subagent. If provided, the rule only applies to tool +# calls made by this specific subagent. +subagent = "generalist" + # (Optional) The name of an MCP server. Can be combined with toolName -# to form a composite name like "mcpName__toolName". +# to form a composite FQN internally like "mcp_mcpName_toolName". mcpName = "my-custom-server" # (Optional) Metadata hints provided by the tool. A rule matches if all @@ -213,14 +278,17 @@ toolAnnotations = { readOnlyHint = true } argsPattern = '"command":"(git|npm)' # (Optional) A string or array of strings that a shell command must start with. -# This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`. -commandPrefix = "git " +# This is syntactic sugar for `toolName = "run_shell_command"` and an +# `argsPattern`. +commandPrefix = "git" # (Optional) A regex to match against the entire shell command. # This is also syntactic sugar for `toolName = "run_shell_command"`. -# Note: This pattern is tested against the JSON representation of the arguments (e.g., `{"command":""}`). -# Because it prepends `"command":"`, it effectively matches from the start of the command. -# Anchors like `^` or `$` apply to the full JSON string, so `^` should usually be avoided here. +# Note: This pattern is tested against the JSON representation of the arguments +# (e.g., `{"command":""}`). Because it prepends `"command":"`, +# it effectively matches from the start of the command. +# Anchors like `^` or `$` apply to the full JSON string, +# so `^` should usually be avoided here. # You cannot use commandPrefix and commandRegex in the same rule. commandRegex = "git (commit|push)" @@ -230,12 +298,18 @@ decision = "ask_user" # The priority of the rule, from 0 to 999. priority = 10 -# (Optional) A custom message to display when a tool call is denied by this rule. -# This message is returned to the model and user, useful for explaining *why* it was denied. +# (Optional) A custom message to display when a tool call is denied by this +# rule. This message is returned to the model and user, +# useful for explaining *why* it was denied. deny_message = "Deletion is permanent" # (Optional) An array of approval modes where this rule is active. modes = ["autoEdit"] + +# (Optional) A boolean to restrict the rule to interactive (true) or +# non-interactive (false) environments. +# If omitted, the rule applies to both. +interactive = true ``` ### Using arrays (lists) @@ -271,7 +345,7 @@ This rule will ask for user confirmation before executing any `git` command. ```toml [[rule]] toolName = "run_shell_command" -commandPrefix = "git " +commandPrefix = "git" decision = "ask_user" priority = 100 ``` @@ -279,11 +353,24 @@ priority = 100 ### Special syntax for MCP tools You can create rules that target tools from Model Context Protocol (MCP) servers -using the `mcpName` field or composite wildcard patterns. +using the `mcpName` field. **This is the recommended approach** for defining MCP +policies, as it is much more robust than manually writing Fully Qualified Names +(FQNs) or string wildcards. + + +> [!WARNING] +> Do not use underscores (`_`) in your MCP server names (e.g., use +> `my-server` rather than `my_server`). The policy parser splits Fully Qualified +> Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` +> prefix. If your server name contains an underscore, the parser will +> misinterpret the server identity, which can cause wildcard rules and security +> policies to fail silently. **1. Targeting a specific tool on a server** -Combine `mcpName` and `toolName` to target a single operation. +Combine `mcpName` and `toolName` to target a single operation. When using +`mcpName`, the `toolName` field should strictly be the simple name of the tool +(e.g., `search`), **not** the Fully Qualified Name (e.g., `mcp_server_search`). ```toml # Allows the `search` tool on the `my-jira-server` MCP @@ -299,6 +386,8 @@ priority = 200 Specify only the `mcpName` to apply a rule to every tool provided by that server. +**Note:** This applies to all decision types (`allow`, `deny`, `ask_user`). + ```toml # Denies all tools from the `untrusted-server` MCP [[rule]] @@ -349,5 +438,3 @@ out-of-the-box experience. - In **`yolo`** mode, a high-priority rule allows all tools. - In **`autoEdit`** mode, rules allow certain write operations to happen without prompting. - -[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies diff --git a/docs/reference/tools-api.md b/docs/reference/tools-api.md deleted file mode 100644 index 91fae3f720..0000000000 --- a/docs/reference/tools-api.md +++ /dev/null @@ -1,131 +0,0 @@ -# Gemini CLI core: Tools API - -The Gemini CLI core (`packages/core`) features a robust system for defining, -registering, and executing tools. These tools extend the capabilities of the -Gemini model, allowing it to interact with the local environment, fetch web -content, and perform various actions beyond simple text generation. - -## Core concepts - -- **Tool (`tools.ts`):** An interface and base class (`BaseTool`) that defines - the contract for all tools. Each tool must have: - - `name`: A unique internal name (used in API calls to Gemini). - - `displayName`: A user-friendly name. - - `description`: A clear explanation of what the tool does, which is provided - to the Gemini model. - - `parameterSchema`: A JSON schema defining the parameters that the tool - accepts. This is crucial for the Gemini model to understand how to call the - tool correctly. - - `validateToolParams()`: A method to validate incoming parameters. - - `getDescription()`: A method to provide a human-readable description of what - the tool will do with specific parameters before execution. - - `shouldConfirmExecute()`: A method to determine if user confirmation is - required before execution (e.g., for potentially destructive operations). - - `execute()`: The core method that performs the tool's action and returns a - `ToolResult`. - -- **`ToolResult` (`tools.ts`):** An interface defining the structure of a tool's - execution outcome: - - `llmContent`: The factual content to be included in the history sent back to - the LLM for context. This can be a simple string or a `PartListUnion` (an - array of `Part` objects and strings) for rich content. - - `returnDisplay`: A user-friendly string (often Markdown) or a special object - (like `FileDiff`) for display in the CLI. - -- **Returning rich content:** Tools are not limited to returning simple text. - The `llmContent` can be a `PartListUnion`, which is an array that can contain - a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a - single tool execution to return multiple pieces of rich content. - -- **Tool registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible - for: - - **Registering tools:** Holding a collection of all available built-in tools - (e.g., `ReadFileTool`, `ShellTool`). - - **Discovering tools:** It can also discover tools dynamically: - - **Command-based discovery:** If `tools.discoveryCommand` is configured in - settings, this command is executed. It's expected to output JSON - describing custom tools, which are then registered as `DiscoveredTool` - instances. - - **MCP-based discovery:** If `mcp.serverCommand` is configured, the - registry can connect to a Model Context Protocol (MCP) server to list and - register tools (`DiscoveredMCPTool`). - - **Providing schemas:** Exposing the `FunctionDeclaration` schemas of all - registered tools to the Gemini model, so it knows what tools are available - and how to use them. - - **Retrieving tools:** Allowing the core to get a specific tool by name for - execution. - -## Built-in tools - -The core comes with a suite of pre-defined tools, typically found in -`packages/core/src/tools/`. These include: - -- **File system tools:** - - `LSTool` (`ls.ts`): Lists directory contents. - - `ReadFileTool` (`read-file.ts`): Reads the content of a single file. - - `WriteFileTool` (`write-file.ts`): Writes content to a file. - - `GrepTool` (`grep.ts`): Searches for patterns in files. - - `GlobTool` (`glob.ts`): Finds files matching glob patterns. - - `EditTool` (`edit.ts`): Performs in-place modifications to files (often - requiring confirmation). - - `ReadManyFilesTool` (`read-many-files.ts`): Reads and concatenates content - from multiple files or glob patterns (used by the `@` command in CLI). -- **Execution tools:** - - `ShellTool` (`shell.ts`): Executes arbitrary shell commands (requires - careful sandboxing and user confirmation). -- **Web tools:** - - `WebFetchTool` (`web-fetch.ts`): Fetches content from a URL. - - `WebSearchTool` (`web-search.ts`): Performs a web search. -- **Memory tools:** - - `MemoryTool` (`memoryTool.ts`): Interacts with the AI's memory. - -Each of these tools extends `BaseTool` and implements the required methods for -its specific functionality. - -## Tool execution flow - -1. **Model request:** The Gemini model, based on the user's prompt and the - provided tool schemas, decides to use a tool and returns a `FunctionCall` - part in its response, specifying the tool name and arguments. -2. **Core receives request:** The core parses this `FunctionCall`. -3. **Tool retrieval:** It looks up the requested tool in the `ToolRegistry`. -4. **Parameter validation:** The tool's `validateToolParams()` method is - called. -5. **Confirmation (if needed):** - - The tool's `shouldConfirmExecute()` method is called. - - If it returns details for confirmation, the core communicates this back to - the CLI, which prompts the user. - - The user's decision (e.g., proceed, cancel) is sent back to the core. -6. **Execution:** If validated and confirmed (or if no confirmation is needed), - the core calls the tool's `execute()` method with the provided arguments and - an `AbortSignal` (for potential cancellation). -7. **Result processing:** The `ToolResult` from `execute()` is received by the - core. -8. **Response to model:** The `llmContent` from the `ToolResult` is packaged as - a `FunctionResponse` and sent back to the Gemini model so it can continue - generating a user-facing response. -9. **Display to user:** The `returnDisplay` from the `ToolResult` is sent to - the CLI to show the user what the tool did. - -## Extending with custom tools - -While direct programmatic registration of new tools by users isn't explicitly -detailed as a primary workflow in the provided files for typical end-users, the -architecture supports extension through: - -- **Command-based discovery:** Advanced users or project administrators can - define a `tools.discoveryCommand` in `settings.json`. This command, when run - by the Gemini CLI core, should output a JSON array of `FunctionDeclaration` - objects. The core will then make these available as `DiscoveredTool` - instances. The corresponding `tools.callCommand` would then be responsible for - actually executing these custom tools. -- **MCP server(s):** For more complex scenarios, one or more MCP servers can be - set up and configured via the `mcpServers` setting in `settings.json`. The - Gemini CLI core can then discover and use tools exposed by these servers. As - mentioned, if you have multiple MCP servers, the tool names will be prefixed - with the server name from your configuration (e.g., - `serverAlias__actualToolName`). - -This tool system provides a flexible and powerful way to augment the Gemini -model's capabilities, making the Gemini CLI a versatile assistant for a wide -range of tasks. diff --git a/docs/reference/tools.md b/docs/reference/tools.md new file mode 100644 index 0000000000..c72888d072 --- /dev/null +++ b/docs/reference/tools.md @@ -0,0 +1,108 @@ +# Tools reference + +Gemini CLI uses tools to interact with your local environment, access +information, and perform actions on your behalf. These tools extend the model's +capabilities beyond text generation, letting it read files, execute commands, +and search the web. + +## How to use Gemini CLI's tools + +Tools are generally invoked automatically by Gemini CLI when it needs to perform +an action. However, you can also trigger specific tools manually using shorthand +syntax. + +### Automatic execution and security + +When the model wants to use a tool, Gemini CLI evaluates the request against its +security policies. + +- **User confirmation:** You must manually approve tools that modify files or + execute shell commands (mutators). The CLI shows you a diff or the exact + command before you confirm. +- **Sandboxing:** You can run tool executions in secure, containerized + environments to isolate changes from your host system. For more details, see + the [Sandboxing](../cli/sandbox.md) guide. +- **Trusted folders:** You can configure which directories allow the model to + use system tools. For more details, see the + [Trusted folders](../cli/trusted-folders.md) guide. + +Review confirmation prompts carefully before allowing a tool to execute. + +### How to use manually-triggered tools + +You can directly trigger key tools using special syntax in your prompt: + +- **[File access](../tools/file-system.md#read_many_files) (`@`):** Use the `@` + symbol followed by a file or directory path to include its content in your + prompt. This triggers the `read_many_files` tool. +- **[Shell commands](../tools/shell.md) (`!`):** Use the `!` symbol followed by + a system command to execute it directly. This triggers the `run_shell_command` + tool. + +## How to manage tools + +Using built-in commands, you can inspect available tools and configure how they +behave. + +### Tool discovery + +Use the `/tools` command to see what tools are currently active in your session. + +- **`/tools`**: Lists all registered tools with their display names. +- **`/tools desc`**: Lists all tools with their full descriptions. + +This is especially useful for verifying that +[MCP servers](../tools/mcp-server.md) or custom tools are loaded correctly. + +### Tool configuration + +You can enable, disable, or configure specific tools in your settings. For +example, you can set a specific pager for shell commands or configure the +browser used for web searches. See the [Settings](../cli/settings.md) guide for +details. + +## Available tools + +The following table lists all available tools, categorized by their primary +function. + +| Category | Tool | Kind | Description | +| :---------- | :----------------------------------------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Execution | [`run_shell_command`](../tools/shell.md) | `Execute` | Executes arbitrary shell commands. Supports interactive sessions and background processes. Requires manual confirmation.

**Parameters:** `command`, `description`, `dir_path`, `is_background` | +| File System | [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace.

**Parameters:** `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` | +| File System | [`grep_search`](../tools/file-system.md) | `Search` | Searches for a regular expression pattern within file contents. Legacy alias: `search_file_content`.

**Parameters:** `pattern`, `dir_path`, `include`, `exclude_pattern`, `names_only`, `max_matches_per_file`, `total_max_matches` | +| File System | [`list_directory`](../tools/file-system.md) | `Read` | Lists the names of files and subdirectories within a specified path.

**Parameters:** `dir_path`, `ignore`, `file_filtering_options` | +| File System | [`read_file`](../tools/file-system.md) | `Read` | Reads the content of a specific file. Supports text, images, audio, and PDF.

**Parameters:** `file_path`, `start_line`, `end_line` | +| File System | [`read_many_files`](../tools/file-system.md) | `Read` | Reads and concatenates content from multiple files. Often triggered by the `@` symbol in your prompt.

**Parameters:** `include`, `exclude`, `recursive`, `useDefaultExcludes`, `file_filtering_options` | +| File System | [`replace`](../tools/file-system.md) | `Edit` | Performs precise text replacement within a file. Requires manual confirmation.

**Parameters:** `file_path`, `instruction`, `old_string`, `new_string`, `allow_multiple` | +| File System | [`write_file`](../tools/file-system.md) | `Edit` | Creates or overwrites a file with new content. Requires manual confirmation.

**Parameters:** `file_path`, `content` | +| Interaction | [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog.

**Parameters:** `questions` | +| Interaction | [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress and display it to you.

**Parameters:** `todos` | +| Memory | [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise for specific tasks from the `.gemini/skills` directory.

**Parameters:** `name` | +| Memory | [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation to provide more accurate answers about its capabilities.

**Parameters:** `path` | +| Memory | [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file to retain context.

**Parameters:** `fact` | +| Planning | [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes.

**Parameters:** `reason` | +| Planning | [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation.

**Parameters:** `plan` | +| System | `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user.

**Parameters:** `result` | +| Web | [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information.

**Parameters:** `query` | +| Web | [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts.

**Parameters:** `prompt` | + +## Under the hood + +For developers, the tool system is designed to be extensible and robust. The +`ToolRegistry` class manages all available tools. + +You can extend Gemini CLI with custom tools by configuring +`tools.discoveryCommand` in your settings or by connecting to MCP servers. + + +> [!NOTE] +> For a deep dive into the internal Tool API and how to implement your +> own tools in the codebase, see the `packages/core/src/tools/` directory in +> GitHub. + +## Next steps + +- Learn how to [Set up an MCP server](../tools/mcp-server.md). +- Explore [Agent Skills](../cli/skills.md) for specialized expertise. +- See the [Command reference](./commands.md) for slash commands. diff --git a/docs/release-confidence.md b/docs/release-confidence.md index f2dcccff4f..c46a702820 100644 --- a/docs/release-confidence.md +++ b/docs/release-confidence.md @@ -21,9 +21,13 @@ All workflows in `.github/workflows/ci.yml` must pass on the `main` branch (for nightly) or the release branch (for preview/stable). - **Platforms:** Tests must pass on **Linux and macOS**. - - _Note:_ Windows tests currently run with `continue-on-error: true`. While a - failure here doesn't block the release technically, it should be - investigated. + + +> [!NOTE] +> Windows tests currently run with `continue-on-error: true`. While a +> failure here doesn't block the release technically, it should be +> investigated. + - **Checks:** - **Linting:** No linting errors (ESLint, Prettier, etc.). - **Typechecking:** No TypeScript errors. @@ -79,8 +83,8 @@ manually run through this checklist. - [ ] Verify version: `gemini --version` - **Authentication:** - - [ ] In interactive mode run `/auth` and verify all login flows work: - - [ ] Login With Google + - [ ] In interactive mode run `/auth` and verify all sign in flows work: + - [ ] Sign in with Google - [ ] API Key - [ ] Vertex AI diff --git a/docs/releases.md b/docs/releases.md index 8b506d45a8..23fb9fcf90 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -234,10 +234,12 @@ This workflow will automatically: Review the automatically created pull request(s) to ensure the cherry-pick was successful and the changes are correct. Once approved, merge the pull request. -**Security note:** The `release/*` branches are protected by branch protection -rules. A pull request to one of these branches requires at least one review from -a code owner before it can be merged. This ensures that no unauthorized code is -released. + +> [!WARNING] +> The `release/*` branches are protected by branch protection +> rules. A pull request to one of these branches requires at least one review from +> a code owner before it can be merged. This ensures that no unauthorized code is +> released. #### 2.5. Adding multiple commits to a hotfix (advanced) @@ -524,9 +526,11 @@ Notifications use [GitHub for Google Chat](https://workspace.google.com/marketplace/app/github_for_google_chat/536184076190). To modify the notifications, use `/github-settings` within the chat space. -> [!WARNING] The following instructions describe a fragile workaround that -> depends on the internal structure of the chat application's UI. It is likely -> to break with future updates. + +> [!WARNING] +> The following instructions describe a fragile workaround that depends on the +> internal structure of the chat application's UI. It is likely to break with +> future updates. The list of available labels is not currently populated correctly. If you want to add a label that does not appear alphabetically in the first 30 labels in the diff --git a/docs/resources/faq.md b/docs/resources/faq.md index eeb0396495..8d1b42d032 100644 --- a/docs/resources/faq.md +++ b/docs/resources/faq.md @@ -5,6 +5,19 @@ problems encountered while using Gemini CLI. ## General issues +This section addresses common questions about Gemini CLI usage, security, and +troubleshooting general errors. + +### Why can't I use third-party software (e.g. Claude Code, OpenClaw, OpenCode) with Gemini CLI? + +Using third-party software, tools, or services to harvest or piggyback on Gemini +CLI's OAuth authentication to access our backend services is a direct violation +of our [applicable terms and policies](tos-privacy.md). Doing so bypasses our +intended authentication and security structures, and such actions may be grounds +for immediate suspension or termination of your account. If you would like to +use a third-party coding agent with Gemini, the supported and secure method is +to use a Vertex AI or Google AI Studio API key. + ### Why am I getting an `API error: 429 - Resource exhausted`? This error indicates that you have exceeded your API request limit. The Gemini @@ -45,6 +58,19 @@ your total token usage using the `/stats` command in Gemini CLI. ## Installation and updates +### How do I check which version of Gemini CLI I'm currently running? + +You can check your current Gemini CLI version using one of these methods: + +- Run `gemini --version` or `gemini -v` from your terminal +- Check the globally installed version using your package manager: + - npm: `npm list -g @google/gemini-cli` + - pnpm: `pnpm list -g @google/gemini-cli` + - yarn: `yarn global list @google/gemini-cli` + - bun: `bun pm ls -g @google/gemini-cli` + - homebrew: `brew list --versions gemini-cli` +- Inside an active Gemini CLI session, use the `/about` command + ### How do I update Gemini CLI to the latest version? If you installed it globally via `npm`, update it using the command @@ -75,10 +101,18 @@ You can configure your Google Cloud Project ID using an environment variable. Set the `GOOGLE_CLOUD_PROJECT` environment variable in your shell: +**macOS/Linux** + ```bash export GOOGLE_CLOUD_PROJECT="your-project-id" ``` +**Windows (PowerShell)** + +```powershell +$env:GOOGLE_CLOUD_PROJECT="your-project-id" +``` + To make this setting permanent, add this line to your shell's startup file (e.g., `~/.bashrc`, `~/.zshrc`). diff --git a/docs/resources/quota-and-pricing.md b/docs/resources/quota-and-pricing.md index d4ed22a1cb..16d6b407b8 100644 --- a/docs/resources/quota-and-pricing.md +++ b/docs/resources/quota-and-pricing.md @@ -1,14 +1,13 @@ # Gemini CLI: Quotas and pricing Gemini CLI offers a generous free tier that covers many individual developers' -use cases. For enterprise or professional usage, or if you need higher limits, +use cases. For enterprise or professional usage, or if you need increased quota, several options are available depending on your authentication account type. -See [privacy and terms](./tos-privacy.md) for details on the Privacy Policy and -Terms of Service. +For a high-level comparison of available subscriptions and to select the right +quota for your needs, see the [Plans page](https://geminicli.com/plans/). -> **Note:** Published prices are list price; additional negotiated commercial -> discounting may apply. +## Overview This article outlines the specific quotas and pricing applicable to Gemini CLI when using different authentication methods. @@ -23,10 +22,11 @@ Generally, there are three categories to choose from: ## Free usage -Your journey begins with a generous free tier, perfect for experimentation and -light use. +Access to Gemini CLI begins with a generous free tier, perfect for +experimentation and light use. -Your free usage limits depend on your authorization type. +Your free usage is governed by the following limits, which depend on your +authorization type. ### Log in with Google (Gemini Code Assist for individuals) @@ -69,6 +69,19 @@ Learn more at If you use up your initial number of requests, you can continue to benefit from Gemini CLI by upgrading to one of the following subscriptions: +### Individuals + +These tiers apply when you sign in with a personal account. To verify whether +you're on a personal account, visit +[Google One](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0): + +- If you are on a personal account, you will see your personal dashboard. +- If you are not on a personal account, you will see: "You're currently signed + in to your Google Workspace Account." + +**Supported tiers:** _- Tiers not listed above, including Google AI Plus, are +not supported._ + - [Google AI Pro and AI Ultra](https://gemini.google/subscriptions/). This is recommended for individual developers. Quotas and pricing are based on a fixed price subscription. @@ -78,14 +91,26 @@ Gemini CLI by upgrading to one of the following subscriptions: Learn more at [Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas) -- [Purchase a Gemini Code Assist Subscription through Google Cloud ](https://cloud.google.com/gemini/docs/codeassist/overview) - by signing up in the Google Cloud console. Learn more at - [Set up Gemini Code Assist](https://cloud.google.com/gemini/docs/discover/set-up-gemini). +### Through your organization + +These tiers are applicable when you are signing in with a Google Workspace +account. + +- To verify your account type, visit + [the Google One page](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0). +- You are on a workspace account if you see the message "You're currently signed + in to your Google Workspace Account". + +**Supported tiers:** _- Tiers not listed above, including Workspace AI +Standard/Plus and AI Expanded, are not supported._ + +- [Workspace AI Ultra Access](https://workspace.google.com/products/ai-ultra/). +- [Purchase a Gemini Code Assist Subscription through Google Cloud](https://cloud.google.com/gemini/docs/codeassist/overview). Quotas and pricing are based on a fixed price subscription with assigned license seats. For predictable costs, you can sign in with Google. - This includes: + This includes the following request limits: - Gemini Code Assist Standard edition: - 1500 model requests / user / day - 120 model requests / user / minute @@ -95,7 +120,7 @@ Gemini CLI by upgrading to one of the following subscriptions: - Model requests will be made across the Gemini model family as determined by Gemini CLI. - [Learn more about Gemini Code Assist Standard and Enterprise license limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli). + [Learn more about Gemini Code Assist license limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli). ## Pay as you go @@ -106,18 +131,27 @@ recommended path for uninterrupted access. To do this, log in using a Gemini API key or Vertex AI. -- Vertex AI (Regular Mode): - - Quota: Governed by a dynamic shared quota system or pre-purchased - provisioned throughput. - - Cost: Based on model and token usage. +### Vertex AI (regular mode) + +An enterprise-grade platform for building, deploying, and managing AI models, +including Gemini. It offers enhanced security, data governance, and integration +with other Google Cloud services. + +- Quota: Governed by a dynamic shared quota system or pre-purchased provisioned + throughput. +- Cost: Based on model and token usage. Learn more at [Vertex AI Dynamic Shared Quota](https://cloud.google.com/vertex-ai/generative-ai/docs/resources/dynamic-shared-quota) and [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing). -- Gemini API key: - - Quota: Varies by pricing tier. - - Cost: Varies by pricing tier and model/token usage. +### Gemini API key + +Ideal for developers who want to quickly build applications with the Gemini +models. This is the most direct way to use the models. + +- Quota: Varies by pricing tier. +- Cost: Varies by pricing tier and model/token usage. Learn more at [Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits), @@ -125,7 +159,8 @@ Learn more at It’s important to highlight that when using an API key, you pay per token/call. This can be more expensive for many small calls with few tokens, but it's the -only way to ensure your workflow isn't interrupted by quota limits. +only way to ensure your workflow isn't interrupted by reaching a limit on your +quota. ## Gemini for workspace plans @@ -135,31 +170,30 @@ Flow video editor). These plans do not apply to the API usage which powers the Gemini CLI. Supporting these plans is under active consideration for future support. -## Check usage and quota +## Check usage and limits -You can check your current token usage and quota information using the +You can check your current token usage and applicable limits using the `/stats model` command. This command provides a snapshot of your current -session's token usage, as well as your overall quota and usage for the supported -models. +session's token usage, as well as information about the limits associated with +your current quota. For more information on the `/stats` command and its subcommands, see the -[Command Reference](../../reference/commands.md#stats). +[Command Reference](../reference/commands.md#stats). A summary of model usage is also presented on exit at the end of a session. ## Tips to avoid high costs -When using a Pay as you Go API key, be mindful of your usage to avoid unexpected +When using a pay-as-you-go plan, be mindful of your usage to avoid unexpected costs. -- Don't blindly accept every suggestion, especially for computationally - intensive tasks like refactoring large codebases. -- Be intentional with your prompts and commands. You are paying per call, so - think about the most efficient way to get the job done. - -## Gemini API vs. Vertex - -- Gemini API (gemini developer api): This is the fastest way to use the Gemini - models directly. -- Vertex AI: This is the enterprise-grade platform for building, deploying, and - managing Gemini models with specific security and control requirements. +- **Be selective with suggestions**: Before accepting a suggestion, especially + for a computationally intensive task like refactoring a large codebase, + consider if it's the most cost-effective approach. +- **Use precise prompts**: You are paying per call, so think about the most + efficient way to get your desired result. A well-crafted prompt can often get + you the answer you need in a single call, rather than multiple back-and-forth + interactions. +- **Monitor your usage**: Use the `/stats model` command to track your token + usage during a session. This can help you stay aware of your spending in real + time. diff --git a/docs/resources/tos-privacy.md b/docs/resources/tos-privacy.md index e653e59d1d..2aaa14cb90 100644 --- a/docs/resources/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -7,11 +7,19 @@ is licensed under the When you use Gemini CLI to access or use Google’s services, the Terms of Service and Privacy Notices applicable to those services apply to such access and use. +Directly accessing the services powering Gemini CLI (e.g., the Gemini Code +Assist service) using third-party software, tools, or services (for example, +using OpenClaw with Gemini CLI OAuth) is a violation of applicable terms and +policies. Such actions may be grounds for suspension or termination of your +account. + Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. -**Note:** See [quotas and pricing](/docs/resources/quota-and-pricing.md) for the -quota and pricing details that apply to your usage of the Gemini CLI. + +> [!NOTE] +> See [quotas and pricing](quota-and-pricing.md) for the quota and +> pricing details that apply to your usage of the Gemini CLI. ## Supported authentication methods @@ -40,7 +48,7 @@ for further information. | Gemini Developer API Key | Gemini API - Paid Services | [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services) | [Google Privacy Policy](https://policies.google.com/privacy) | | Vertex AI GenAI API Key | Vertex AI GenAI API | [Google Cloud Platform Terms of Service](https://cloud.google.com/terms/service-terms/) | [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice) | -## 1. If you have logged in with your Google account to Gemini Code Assist +## 1. If you have signed in with your Google account to Gemini Code Assist For users who use their Google account to access [Gemini Code Assist](https://codeassist.google), these Terms of Service and @@ -62,7 +70,7 @@ Code Assist Standard or Enterprise edition, the terms and privacy policy of Gemini Code Assist Standard or Enterprise edition will apply to all your use of Gemini Code Assist._ -## 2. If you have logged in with a Gemini API key to the Gemini Developer API +## 2. If you have signed in with a Gemini API key to the Gemini Developer API If you are using a Gemini API key for authentication with the [Gemini Developer API](https://ai.google.dev/gemini-api/docs), these Terms of @@ -78,7 +86,7 @@ Service and Privacy Notice documents apply: - Privacy Notice: The collection and use of your data is described in the [Google Privacy Policy](https://policies.google.com/privacy). -## 3. If you have logged in with a Gemini API key to the Vertex AI GenAI API +## 3. If you have signed in with a Gemini API key to the Vertex AI GenAI API If you are using a Gemini API key for authentication with a [Vertex AI GenAI API](https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest) diff --git a/docs/resources/troubleshooting.md b/docs/resources/troubleshooting.md index 9e567652d9..f490d41ffe 100644 --- a/docs/resources/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -29,13 +29,13 @@ topics on: added to your organization's Gemini Code Assist subscription. - **Error: - `Failed to login. Message: Your current account is not eligible... because it is not currently available in your location.`** + `Failed to sign in. Message: Your current account is not eligible... because it is not currently available in your location.`** - **Cause:** Gemini CLI does not currently support your location. For a full list of supported locations, see the following pages: - Gemini Code Assist for individuals: [Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas) -- **Error: `Failed to login. Message: Request contains an invalid argument`** +- **Error: `Failed to sign in. Message: Request contains an invalid argument`** - **Cause:** Users with Google Workspace accounts or Google Cloud accounts associated with their Gmail accounts may not be able to activate the free tier of the Google Code Assist plan. @@ -55,10 +55,13 @@ topics on: - Set the `NODE_USE_SYSTEM_CA=1` environment variable to tell Node.js to use the operating system's native certificate store (where corporate certificates are typically already installed). - - Example: `export NODE_USE_SYSTEM_CA=1` + - Example: `export NODE_USE_SYSTEM_CA=1` (Windows PowerShell: + `$env:NODE_USE_SYSTEM_CA=1`) - Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file. - Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt` + (Windows PowerShell: + `$env:NODE_EXTRA_CA_CERTS="C:\path\to\your\corporate-ca.crt"`) ## Common error messages and solutions @@ -121,6 +124,21 @@ topics on: `advanced.excludedEnvVars` setting in your `settings.json` to exclude fewer variables. +- **Warning: `npm WARN deprecated node-domexception@1.0.0` or + `npm WARN deprecated glob` during install/update** + - **Issue:** When installing or updating the Gemini CLI globally via + `npm install -g @google/gemini-cli` or `npm update -g @google/gemini-cli`, + you might see deprecation warnings regarding `node-domexception` or old + versions of `glob`. + - **Cause:** These warnings occur because some dependencies (or their + sub-dependencies, like `google-auth-library`) rely on older package + versions. Since Gemini CLI requires Node.js 20 or higher, the platform's + native features (like the native `DOMException`) are used, making these + warnings purely informational. + - **Solution:** These warnings are harmless and can be safely ignored. Your + installation or update will complete successfully and function properly + without any action required. + ## Exit codes The Gemini CLI uses specific exit codes to indicate the reason for termination. @@ -169,5 +187,7 @@ guide_, consider searching the Gemini CLI If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome! -> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project + +> [!NOTE] +> Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. diff --git a/docs/sidebar.json b/docs/sidebar.json index c2c6295bfa..7198a0336b 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -47,6 +47,11 @@ "label": "Plan tasks with todos", "slug": "docs/cli/tutorials/task-planning" }, + { + "label": "Use Plan Mode with model steering", + "badge": "🔬", + "slug": "docs/cli/tutorials/plan-mode-steering" + }, { "label": "Web search and fetch", "slug": "docs/cli/tutorials/web-tools" @@ -94,12 +99,34 @@ { "label": "Agent Skills", "slug": "docs/cli/skills" }, { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, - { "label": "Hooks", "slug": "docs/hooks" }, + { + "label": "Git worktrees", + "badge": "🔬", + "slug": "docs/cli/git-worktrees" + }, + { + "label": "Hooks", + "collapsed": true, + "items": [ + { "label": "Overview", "slug": "docs/hooks" }, + { "label": "Reference", "slug": "docs/hooks/reference" } + ] + }, { "label": "IDE integration", "slug": "docs/ide-integration" }, { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, { "label": "Model routing", "slug": "docs/cli/model-routing" }, { "label": "Model selection", "slug": "docs/cli/model" }, - { "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" }, + { + "label": "Model steering", + "badge": "🔬", + "slug": "docs/cli/model-steering" + }, + { + "label": "Notifications", + "badge": "🔬", + "slug": "docs/cli/notifications" + }, + { "label": "Plan mode", "slug": "docs/cli/plan-mode" }, { "label": "Subagents", "badge": "🔬", @@ -181,7 +208,7 @@ "slug": "docs/reference/memport" }, { "label": "Policy engine", "slug": "docs/reference/policy-engine" }, - { "label": "Tools API", "slug": "docs/reference/tools-api" } + { "label": "Tools reference", "slug": "docs/reference/tools" } ] } ] diff --git a/docs/tools/ask-user.md b/docs/tools/ask-user.md index 8c086acdba..14770b4c99 100644 --- a/docs/tools/ask-user.md +++ b/docs/tools/ask-user.md @@ -25,7 +25,8 @@ confirmation. - `label` (string, required): Display text (1-5 words). - `description` (string, required): Brief explanation. - `multiSelect` (boolean, optional): For `'choice'` type, allows selecting - multiple options. + multiple options. Automatically adds an "All the above" option if there + are multiple standard options. - `placeholder` (string, optional): Hint text for input fields. - **Behavior:** diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md index 09c792f84d..a6beb1d76d 100644 --- a/docs/tools/file-system.md +++ b/docs/tools/file-system.md @@ -67,7 +67,7 @@ Finds files matching specific glob patterns across the workspace. `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...` - **Confirmation:** No. -## 5. `grep_search` (SearchText) +### `grep_search` (SearchText) `grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the @@ -103,7 +103,7 @@ lines containing matches, along with their file paths and line numbers. ``` - **Confirmation:** No. -## 6. `replace` (Edit) +### `replace` (Edit) `replace` replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of `old_string`. If you want to replace multiple diff --git a/docs/tools/index.md b/docs/tools/index.md deleted file mode 100644 index 6bdf298fea..0000000000 --- a/docs/tools/index.md +++ /dev/null @@ -1,105 +0,0 @@ -# Gemini CLI tools - -Gemini CLI uses tools to interact with your local environment, access -information, and perform actions on your behalf. These tools extend the model's -capabilities beyond text generation, letting it read files, execute commands, -and search the web. - -## User-triggered tools - -You can directly trigger these tools using special syntax in your prompts. - -- **[File access](./file-system.md#read_many_files) (`@`):** Use the `@` symbol - followed by a file or directory path to include its content in your prompt. - This triggers the `read_many_files` tool. -- **[Shell commands](./shell.md) (`!`):** Use the `!` symbol followed by a - system command to execute it directly. This triggers the `run_shell_command` - tool. - -## Model-triggered tools - -The Gemini model automatically requests these tools when it needs to perform -specific actions or gather information to fulfill your requests. You do not call -these tools manually. - -### File management - -These tools let the model explore and modify your local codebase. - -- **[Directory listing](./file-system.md#list_directory) (`list_directory`):** - Lists files and subdirectories. -- **[File reading](./file-system.md#read_file) (`read_file`):** Reads the - content of a specific file. -- **[File writing](./file-system.md#write_file) (`write_file`):** Creates or - overwrites a file with new content. -- **[File search](./file-system.md#glob) (`glob`):** Finds files matching a glob - pattern. -- **[Text search](./file-system.md#search_file_content) - (`search_file_content`):** Searches for text within files using grep or - ripgrep. -- **[Text replacement](./file-system.md#replace) (`replace`):** Performs precise - edits within a file. - -### Agent coordination - -These tools help the model manage its plan and interact with you. - -- **Ask user (`ask_user`):** Requests clarification or missing information from - you via an interactive dialog. -- **[Memory](./memory.md) (`save_memory`):** Saves important facts to your - long-term memory (`GEMINI.md`). -- **[Todos](./todos.md) (`write_todos`):** Manages a list of subtasks for - complex plans. -- **[Agent Skills](../cli/skills.md) (`activate_skill`):** Loads specialized - procedural expertise when needed. -- **[Browser agent](../core/subagents.md#browser-agent-experimental) - (`browser_agent`):** Automates web browser tasks through the accessibility - tree. -- **Internal docs (`get_internal_docs`):** Accesses Gemini CLI's own - documentation to help answer your questions. - -### Information gathering - -These tools provide the model with access to external data. - -- **[Web fetch](./web-fetch.md) (`web_fetch`):** Retrieves and processes content - from specific URLs. -- **[Web search](./web-search.md) (`google_web_search`):** Performs a Google - Search to find up-to-date information. - -## How to use tools - -You use tools indirectly by providing natural language prompts to Gemini CLI. - -1. **Prompt:** You enter a request or use syntax like `@` or `!`. -2. **Request:** The model analyzes your request and identifies if a tool is - required. -3. **Validation:** If a tool is needed, the CLI validates the parameters and - checks your security settings. -4. **Confirmation:** For sensitive operations (like writing files), the CLI - prompts you for approval. -5. **Execution:** The tool runs, and its output is sent back to the model. -6. **Response:** The model uses the results to generate a final, grounded - answer. - -## Security and confirmation - -Safety is a core part of the tool system. To protect your system, Gemini CLI -implements several safeguards. - -- **User confirmation:** You must manually approve tools that modify files or - execute shell commands. The CLI shows you a diff or the exact command before - you confirm. -- **Sandboxing:** You can run tool executions in secure, containerized - environments to isolate changes from your host system. For more details, see - the [Sandboxing](../cli/sandbox.md) guide. -- **Trusted folders:** You can configure which directories allow the model to - use system tools. - -Always review confirmation prompts carefully before allowing a tool to execute. - -## Next steps - -- Learn how to [Provide context](../cli/gemini-md.md) to guide tool use. -- Explore the [Command reference](../reference/commands.md) for tool-related - slash commands. diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 22ce748918..9fc84d54c0 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -176,8 +176,8 @@ Each server configuration supports the following properties: enabled by default. - **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are - exposed by the server. **Note:** `excludeTools` takes precedence over - `includeTools` - if a tool is in both lists, it will be excluded. + exposed by the server. `excludeTools` takes precedence over `includeTools`. If + a tool is in both lists, it will be excluded. - **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`. @@ -238,7 +238,9 @@ This follows the security principle that if a variable is explicitly configured by the user for a specific server, it constitutes informed consent to share that specific data with that server. -> **Note:** Even when explicitly defined, you should avoid hardcoding secrets. + +> [!NOTE] +> Even when explicitly defined, you should avoid hardcoding secrets. > Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to > securely pull the value from your host environment at runtime. @@ -283,10 +285,12 @@ When connecting to an OAuth-enabled server: #### Browser redirect requirements -**Important:** OAuth authentication requires that your local machine can: - -- Open a web browser for authentication -- Receive redirects on `http://localhost:7777/oauth/callback` + +> [!IMPORTANT] +> OAuth authentication requires that your local machine can: +> +> - Open a web browser for authentication +> - Receive redirects on `http://localhost:7777/oauth/callback` This feature will not work in: @@ -372,7 +376,7 @@ To authenticate with a server using Service Account Impersonation, you must set the `authProviderType` to `service_account_impersonation` and provide the following properties: -- **`targetAudience`** (string): The OAuth Client ID allowslisted on the +- **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. - **`targetServiceAccount`** (string): The email address of the Google Cloud Service Account to impersonate. @@ -555,21 +559,36 @@ Upon successful connection: `excludeTools` configuration 4. **Name sanitization:** Tool names are cleaned to meet Gemini API requirements: - - Invalid characters (non-alphanumeric, underscore, dot, hyphen) are replaced - with underscores + - Characters other than letters, numbers, underscore (`_`), hyphen (`-`), dot + (`.`), and colon (`:`) are replaced with underscores - Names longer than 63 characters are truncated with middle replacement - (`___`) + (`...`) -### 3. Conflict resolution +### 3. Tool naming and namespaces -When multiple servers expose tools with the same name: +To prevent collisions across multiple servers or conflicting built-in tools, +every discovered MCP tool is assigned a strict namespace. -1. **First registration wins:** The first server to register a tool name gets - the unprefixed name -2. **Automatic prefixing:** Subsequent servers get prefixed names: - `serverName__toolName` -3. **Registry tracking:** The tool registry maintains mappings between server - names and their tools +1. **Automatic FQN:** All MCP tools are unconditionally assigned a fully + qualified name (FQN) using the format `mcp_{serverName}_{toolName}`. +2. **Registry tracking:** The tool registry maintains metadata mappings between + these FQNs and their original server identities. +3. **Overwrites:** If two servers share the exact same alias in your + configuration and provide tools with the exact same name, the last registered + tool overwrites the previous one. +4. **Policies:** To configure permissions (like auto-approval or denial) for MCP + tools, see + [Special syntax for MCP tools](../reference/policy-engine.md#special-syntax-for-mcp-tools) + in the Policy Engine documentation. + + +> [!WARNING] +> Do not use underscores (`_`) in your MCP server names (e.g., use +> `my-server` rather than `my_server`). The policy parser splits Fully Qualified +> Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` +> prefix. If your server name contains an underscore, the parser will +> misinterpret the server identity, which can cause wildcard rules and security +> policies to fail silently. ### 4. Schema processing @@ -695,7 +714,7 @@ MCP Servers Status: 🐳 dockerizedServer (CONNECTED) Command: docker run -i --rm -e API_KEY my-mcp-server:latest - Tools: docker__deploy, docker__status + Tools: mcp_dockerizedServer_docker_deploy, mcp_dockerizedServer_docker_status Discovery State: COMPLETED ``` @@ -716,6 +735,43 @@ tools. The model will automatically: The MCP integration tracks several states: +#### Overriding extension configurations + +If an MCP server is provided by an extension (for example, the +`google-workspace` extension), you can still override its settings in your local +`settings.json`. Gemini CLI merges your local configuration with the extension's +defaults: + +- **Tool lists:** Tool lists are merged securely to ensure the most restrictive + policy wins: + - **Exclusions (`excludeTools`):** Arrays are combined (unioned). If either + source blocks a tool, it remains disabled. + - **Inclusions (`includeTools`):** Arrays are intersected. If both sources + provide an allowlist, only tools present in **both** lists are enabled. If + only one source provides an allowlist, that list is respected. + - **Precedence:** `excludeTools` always takes precedence over `includeTools`. + + This ensures you always have veto power over tools provided by an extension + and that an extension cannot re-enable tools you have omitted from your + personal allowlist. + +- **Environment variables:** The `env` objects are merged. If the same variable + is defined in both places, your local value takes precedence. +- **Scalar properties:** Properties like `command`, `url`, and `timeout` are + replaced by your local values if provided. + +**Example override:** + +```json +{ + "mcpServers": { + "google-workspace": { + "excludeTools": ["gmail.send"] + } + } +} +``` + #### Server status (`MCPServerStatus`) - **`DISCONNECTED`:** Server is not connected or has errors @@ -1066,6 +1122,13 @@ command has no flags. gemini mcp list ``` + +> [!NOTE] +> For security, `stdio` MCP servers (those using the +> `command` property) are only tested and displayed as "Connected" if the +> current folder is trusted. If the folder is untrusted, they will show as +> "Disconnected". Use `gemini trust` to trust the current folder. + **Example output:** ```sh @@ -1074,6 +1137,23 @@ gemini mcp list ✗ sse-server: https://api.example.com/sse (sse) - Disconnected ``` +## Troubleshooting and Diagnostics + +To minimize noise during startup, MCP connection errors for background servers +are "silent by default." If issues are detected during startup, a single +informational hint will be shown: _"MCP issues detected. Run /mcp list for +status."_ + +Detailed, actionable diagnostics for a specific server are automatically +re-enabled when: + +1. You run an interactive command like `/mcp list`, `/mcp auth`, etc. +2. The model attempts to execute a tool from that server. +3. You invoke an MCP prompt from that server. + +You can also use `gemini mcp list` from your shell to see connection errors for +all configured servers. + ### Removing a server (`gemini mcp remove`) To delete a server from your configuration, use the `remove` command with the diff --git a/docs/tools/planning.md b/docs/tools/planning.md index 458b172510..e554e47a34 100644 --- a/docs/tools/planning.md +++ b/docs/tools/planning.md @@ -1,8 +1,8 @@ # Gemini CLI planning tools -Planning tools allow the Gemini model to switch into a safe, read-only "Plan -Mode" for researching and planning complex changes, and to signal the -finalization of a plan to the user. +Planning tools let Gemini CLI switch into a safe, read-only "Plan Mode" for +researching and planning complex changes, and to signal the finalization of a +plan to the user. ## 1. `enter_plan_mode` (EnterPlanMode) @@ -11,18 +11,21 @@ by the agent when you ask it to "start a plan" using natural language. In this mode, the agent is restricted to read-only tools to allow for safe exploration and planning. -> **Note:** This tool is not available when the CLI is in YOLO mode. + +> [!NOTE] +> This tool is not available when the CLI is in YOLO mode. - **Tool name:** `enter_plan_mode` - **Display name:** Enter Plan Mode - **File:** `enter-plan-mode.ts` - **Parameters:** - `reason` (string, optional): A short reason explaining why the agent is - entering plan mode (e.g., "Starting a complex feature implementation"). + entering plan mode (for example, "Starting a complex feature + implementation"). - **Behavior:** - Switches the CLI's approval mode to `PLAN`. - Notifies the user that the agent has entered Plan Mode. -- **Output (`llmContent`):** A message indicating the switch, e.g., +- **Output (`llmContent`):** A message indicating the switch, for example, `Switching to Plan mode.` - **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode. @@ -37,7 +40,7 @@ finalized plan to the user and requests approval to start the implementation. - **Parameters:** - `plan_path` (string, required): The path to the finalized Markdown plan file. This file MUST be located within the project's temporary plans - directory (e.g., `~/.gemini/tmp//plans/`). + directory (for example, `~/.gemini/tmp//plans/`). - **Behavior:** - Validates that the `plan_path` is within the allowed directory and that the file exists and has content. diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 34fd7c8490..26f0769e98 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -57,8 +57,8 @@ implementation, which does not support interactive commands. ### Showing color in output To show color in the shell output, you need to set the `tools.shell.showColor` -setting to `true`. **Note: This setting only applies when -`tools.shell.enableInteractiveShell` is enabled.** +setting to `true`. This setting only applies when +`tools.shell.enableInteractiveShell` is enabled. **Example `settings.json`:** @@ -75,8 +75,8 @@ setting to `true`. **Note: This setting only applies when ### Setting the pager You can set a custom pager for the shell output by setting the -`tools.shell.pager` setting. The default pager is `cat`. **Note: This setting -only applies when `tools.shell.enableInteractiveShell` is enabled.** +`tools.shell.pager` setting. The default pager is `cat`. This setting only +applies when `tools.shell.enableInteractiveShell` is enabled. **Example `settings.json`:** @@ -120,6 +120,14 @@ tools to detect if they are being run from within the Gemini CLI. ## Command restrictions + +> [!WARNING] +> The `tools.core` setting is an **allowlist for _all_ built-in +> tools**, not just shell commands. When you set `tools.core` to any value, +> _only_ the tools explicitly listed will be enabled. This includes all built-in +> tools like `read_file`, `write_file`, `glob`, `grep_search`, `list_directory`, +> `replace`, etc. + You can restrict the commands that can be executed by the `run_shell_command` tool by using the `tools.core` and `tools.exclude` settings in your configuration file. diff --git a/docs/tools/todos.md b/docs/tools/todos.md index abb44c0927..d198b872ea 100644 --- a/docs/tools/todos.md +++ b/docs/tools/todos.md @@ -13,7 +13,8 @@ updates to the CLI interface. - `todos` (array of objects, required): The complete list of tasks. Each object includes: - `description` (string): Technical description of the task. - - `status` (enum): `pending`, `in_progress`, `completed`, or `cancelled`. + - `status` (enum): `pending`, `in_progress`, `completed`, `cancelled`, or + `blocked`. ## Technical behavior diff --git a/esbuild.config.js b/esbuild.config.js index 3ecf678088..f0d55e3ca6 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -82,12 +82,18 @@ const commonAliases = { const cliConfig = { ...baseConfig, banner: { - js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`, + js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`, }, - entryPoints: ['packages/cli/index.ts'], - outfile: 'bundle/gemini.js', + entryPoints: { gemini: 'packages/cli/index.ts' }, + outdir: 'bundle', + splitting: true, define: { + __filename: '__chunk_filename', + __dirname: '__chunk_dirname', 'process.env.CLI_VERSION': JSON.stringify(pkg.version), + 'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify( + pkg.config?.sandboxImageUri, + ), }, plugins: createWasmPlugins(), alias: { @@ -100,11 +106,13 @@ const cliConfig = { const a2aServerConfig = { ...baseConfig, banner: { - js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`, + js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`, }, entryPoints: ['packages/a2a-server/src/http/server.ts'], outfile: 'packages/a2a-server/dist/a2a-server.mjs', define: { + __filename: '__chunk_filename', + __dirname: '__chunk_dirname', 'process.env.CLI_VERSION': JSON.stringify(pkg.version), }, plugins: createWasmPlugins(), diff --git a/eslint.config.js b/eslint.config.js index 3bc350d027..e827f9b236 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,11 +25,29 @@ const __dirname = path.dirname(__filename); const projectRoot = __dirname; const currentYear = new Date().getFullYear(); +const commonRestrictedSyntaxRules = [ + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, + { + selector: + 'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]', + message: + 'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.', + }, +]; + export default tseslint.config( { // Global ignores ignores: [ - 'node_modules/*', + '**/node_modules/**', 'eslint.config.js', 'packages/**/dist/**', 'bundle/**', @@ -38,7 +56,8 @@ export default tseslint.config( 'dist/**', 'evals/**', 'packages/test-utils/**', - '.gemini/skills/**', + '.gemini/**', + '**/*.d.ts', ], }, eslint.configs.recommended, @@ -55,26 +74,8 @@ export default tseslint.config( }, }, { - // Import specific config - files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages - plugins: { - import: importPlugin, - }, - settings: { - 'import/resolver': { - node: true, - }, - }, - rules: { - ...importPlugin.configs.recommended.rules, - ...importPlugin.configs.typescript.rules, - 'import/no-default-export': 'warn', - 'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths - }, - }, - { - // General overrides and rules for the project (TS/TSX files) - files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + // Rules for packages/*/src (TS/TSX) + files: ['packages/*/src/**/*.{ts,tsx}'], plugins: { import: importPlugin, }, @@ -95,6 +96,11 @@ export default tseslint.config( }, }, rules: { + ...importPlugin.configs.recommended.rules, + ...importPlugin.configs.typescript.rules, + 'import/no-default-export': 'warn', + 'import/no-unresolved': 'off', + 'import/no-duplicates': 'error', // General Best Practice Rules (subset adapted for flat config) '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 'arrow-body-style': ['error', 'as-needed'], @@ -133,18 +139,7 @@ export default tseslint.config( 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, - ], + 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], 'no-unsafe-finally': 'error', 'no-unused-expressions': 'off', // Disable base rule '@typescript-eslint/no-unused-expressions': [ @@ -163,6 +158,7 @@ export default tseslint.config( '@typescript-eslint/await-thenable': ['error'], '@typescript-eslint/no-floating-promises': ['error'], '@typescript-eslint/no-unnecessary-type-assertion': ['error'], + '@typescript-eslint/no-misused-spread': ['error'], 'no-restricted-imports': [ 'error', { @@ -184,14 +180,51 @@ export default tseslint.config( ], }, }, + { + // API Response Optionality enforcement for Code Assist + files: ['packages/core/src/code_assist/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + ...commonRestrictedSyntaxRules, + { + selector: + 'TSInterfaceDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', + message: + 'All fields in API response interfaces (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', + }, + { + selector: + 'TSTypeAliasDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', + message: + 'All fields in API response types (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', + }, + ], + }, + }, { // Rules that only apply to product code files: ['packages/*/src/**/*.{ts,tsx}'], - ignores: ['**/*.test.ts', '**/*.test.tsx'], + ignores: ['**/*.test.ts', '**/*.test.tsx', 'packages/*/src/test-utils/**'], rules: { '@typescript-eslint/no-unsafe-type-assertion': 'error', '@typescript-eslint/no-unsafe-assignment': 'error', '@typescript-eslint/no-unsafe-return': 'error', + 'no-restricted-syntax': [ + 'error', + ...commonRestrictedSyntaxRules, + { + selector: + 'CallExpression[callee.object.name="Object"][callee.property.name="create"]', + message: + 'Avoid using Object.create() in product code. Use object spread {...obj}, explicit class instantiation, structuredClone(), or copy constructors instead.', + }, + { + selector: 'Identifier[name="Reflect"]', + message: + 'Avoid using Reflect namespace in product code. Do not use reflection to make copies. Instead, use explicit object copying or cloning (structuredClone() for values, new instance/clone function for classes).', + }, + ], }, }, { @@ -253,6 +286,7 @@ export default tseslint.config( ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', + 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], }, }, { @@ -283,7 +317,12 @@ export default tseslint.config( }, }, { - files: ['./scripts/**/*.js', 'esbuild.config.js'], + files: [ + './scripts/**/*.js', + 'packages/*/scripts/**/*.js', + 'esbuild.config.js', + 'packages/core/scripts/**/*.{js,mjs}', + ], languageOptions: { globals: { ...globals.node, diff --git a/evals/README.md b/evals/README.md index eb3cf2be70..6cfecbad07 100644 --- a/evals/README.md +++ b/evals/README.md @@ -3,7 +3,8 @@ Behavioral evaluations (evals) are tests designed to validate the agent's behavior in response to specific prompts. They serve as a critical feedback loop for changes to system prompts, tool definitions, and other model-steering -mechanisms. +mechanisms, and as a tool for assessing feature reliability by model, and +preventing regressions. ## Why Behavioral Evals? @@ -30,6 +31,48 @@ CLI's features. those that are generally reliable but might occasionally vary (`USUALLY_PASSES`). +## Best Practices + +When designing behavioral evals, aim for scenarios that accurately reflect +real-world usage while remaining small and maintainable. + +- **Realistic Complexity**: Evals should be complicated enough to be + "realistic." They should operate on actual files and a source directory, + mirroring how a real agent interacts with a workspace. Remember that the agent + may behave differently in a larger codebase, so we want to avoid scenarios + that are too simple to be realistic. + - _Good_: An eval that provides a small, functional React component and asks + the agent to add a specific feature, requiring it to read the file, + understand the context, and write the correct changes. + - _Bad_: An eval that simply asks the agent a trivia question or asks it to + write a generic script without providing any local workspace context. +- **Maintainable Size**: Evals should be small enough to reason about and + maintain. We probably can't check in an entire repo as a test case, though + over time we will want these evals to mature into more and more realistic + scenarios. + - _Good_: A test setup with 2-3 files (e.g., a source file, a config file, and + a test file) that isolates the specific behavior being evaluated. + - _Bad_: A test setup containing dozens of files from a complex framework + where the setup logic itself is prone to breaking. +- **Unambiguous and Reliable Assertions**: Assertions must be clear and specific + to ensure the test passes for the right reason. + - _Good_: Checking that a modified file contains a specific AST node or exact + string, or verifying that a tool was called with with the right parameters. + - _Bad_: Only checking for a tool call, which could happen for an unrelated + reason. Expecting specific LLM output. +- **Fail First**: Have tests that failed before your prompt or tool change. We + want to be sure the test fails before your "fix". It's pretty easy to + accidentally create a passing test that asserts behaviors we get for free. In + general, every eval should be accompanied by prompt change, and most prompt + changes should be accompanied by an eval. + - _Good_: Observing a failure, writing an eval that reliably reproduces the + failure, modifying the prompt/tool, and then verifying the eval passes. + - _Bad_: Writing an eval that passes on the first run and assuming your new + prompt change was responsible. +- **Less is More**: Prefer fewer, more realistic tests that assert the major + paths vs. more tests that are more unit-test like. These are evals, so the + value is in testing how the agent works in a semi-realistic scenario. + ## Creating an Evaluation Evaluations are located in the `evals` directory. Each evaluation is a Vitest @@ -46,18 +89,20 @@ two arguments: #### Policies -Policies control how strictly a test is validated. Tests should generally use -the ALWAYS_PASSES policy to offer the strictest guarantees. - -USUALLY_PASSES exists to enable assertion of less consistent or aspirational -behaviors. +Policies control how strictly a test is validated. - `ALWAYS_PASSES`: Tests expected to pass 100% of the time. These are typically - trivial and test basic functionality. These run in every CI. + trivial and test basic functionality. These run in every CI and can block PRs + on failure. - `USUALLY_PASSES`: Tests expected to pass most of the time but may have some flakiness due to non-deterministic behaviors. These are run nightly and used to track the health of the product from build to build. +**All new behavioral evaluations must be created with the `USUALLY_PASSES` +policy.** A subset that prove to be highly stable over time may be promoted to +`ALWAYS_PASSES`. For more information, see +[Test promotion process](#test-promotion-process). + #### `EvalCase` Properties - `name`: The name of the evaluation case. @@ -76,7 +121,8 @@ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('my_feature', () => { - evalTest('ALWAYS_PASSES', { + // New tests MUST start as USUALLY_PASSES and be promoted via /promote-behavioral-eval + evalTest('USUALLY_PASSES', { name: 'should do something', prompt: 'do it', assert: async (rig, result) => { @@ -114,6 +160,39 @@ npm run test:all_evals This command sets the `RUN_EVALS` environment variable to `1`, which enables the `USUALLY_PASSES` tests. +## Ensuring Eval is Stable Prior to Check-in + +The +[Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml) +run is considered to be the source of truth for the quality of an eval test. +Each run of it executes a test 3 times in a row, for each supported model. The +result is then scored 0%, 33%, 66%, or 100% respectively, to indicate how many +of the individual executions passed. + +Googlers can schedule a manual run against their branch by clicking the link +above. + +Tests should score at least 66% with key models including Gemini 3.1 pro, Gemini +3.0 pro, and Gemini 3 flash prior to check in and they must pass 100% of the +time before they are promoted. + +## Test promotion process + +To maintain a stable and reliable CI, all new behavioral evaluations follow a +mandatory deflaking process. + +1. **Incubation**: You must create all new tests with the `USUALLY_PASSES` + policy. This lets them be monitored in the nightly runs without blocking PRs. +2. **Monitoring**: The test must complete at least 10 nightly runs across all + supported models. +3. **Promotion**: Promotion to `ALWAYS_PASSES` happens exclusively through the + `/promote-behavioral-eval` slash command. This command verifies the 100% + success rate requirement is met across many runs before updating the test + policy. + +This promotion process is essential for preventing the introduction of flaky +evaluations into the CI. + ## Reporting Results for evaluations are available on GitHub Actions: @@ -135,7 +214,7 @@ aggregated into a **Nightly Summary** attached to the workflow run. - **Pass Rate (%)**: Each cell represents the percentage of successful runs for a specific test in that workflow instance. -- **History**: The table shows the pass rates for the last 10 nightly runs, +- **History**: The table shows the pass rates for the last 7 nightly runs, allowing you to identify if a model's behavior is trending towards instability. - **Total Pass Rate**: An aggregate metric of all evaluations run in that batch. @@ -184,8 +263,35 @@ gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/ When investigating failures manually, you can also enable verbose agent logs by setting the `GEMINI_DEBUG_LOG_FILE` environment variable. +### Best practices + It's highly recommended to manually review and/or ask the agent to iterate on any prompt changes, even if they pass all evals. The prompt should prefer positive traits ('do X') and resort to negative traits ('do not do X') only when unable to accomplish the goal with positive traits. Gemini is quite good at instrospecting on its prompt when asked the right questions. + +## Promoting evaluations + +Evaluations must be promoted from `USUALLY_PASSES` to `ALWAYS_PASSES` +exclusively using the `/promote-behavioral-eval` slash command. Manual promotion +is not allowed to ensure that the 100% success rate requirement is empirically +met. + +### `/promote-behavioral-eval` + +This command automates the promotion of stable tests by: + +1. **Investigating**: Analyzing the results of the last 7 nightly runs on the + `main` branch using the `gh` CLI. +2. **Criteria Check**: Identifying tests that have passed 100% of the time for + ALL enabled models across the entire 7-run history. +3. **Promotion**: Updating the test file's policy from `USUALLY_PASSES` to + `ALWAYS_PASSES`. +4. **Verification**: Running the promoted test locally to ensure correctness. + +To run it: + +```bash +gemini /promote-behavioral-eval +``` diff --git a/evals/answer-vs-act.eval.ts b/evals/answer-vs-act.eval.ts index 7ee273fc31..ff87d12564 100644 --- a/evals/answer-vs-act.eval.ts +++ b/evals/answer-vs-act.eval.ts @@ -88,7 +88,7 @@ describe('Answer vs. ask eval', () => { * Ensures that when the user asks a general question, the agent does NOT * automatically modify the file. */ - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should not edit files when asked a general question', prompt: 'How does app.ts work?', files: FILES, @@ -111,7 +111,7 @@ describe('Answer vs. ask eval', () => { * Ensures that when the user asks a question about style, the agent does NOT * automatically modify the file. */ - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should not edit files when asked about style', prompt: 'Is app.ts following good style?', files: FILES, diff --git a/evals/app-test-helper.ts b/evals/app-test-helper.ts index 89f1582bdc..2bcff41924 100644 --- a/evals/app-test-helper.ts +++ b/evals/app-test-helper.ts @@ -15,9 +15,26 @@ import fs from 'node:fs'; import path from 'node:path'; import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core'; +/** + * Config overrides for evals, with tool-restriction fields explicitly + * forbidden. Evals must test against the full, default tool set to ensure + * realistic behavior. + */ +interface EvalConfigOverrides { + /** Restricting tools via excludeTools in evals is forbidden. */ + excludeTools?: never; + /** Restricting tools via coreTools in evals is forbidden. */ + coreTools?: never; + /** Restricting tools via allowedTools in evals is forbidden. */ + allowedTools?: never; + /** Restricting tools via mainAgentTools in evals is forbidden. */ + mainAgentTools?: never; + [key: string]: unknown; +} + export interface AppEvalCase { name: string; - configOverrides?: any; + configOverrides?: EvalConfigOverrides; prompt: string; timeout?: number; files?: Record; diff --git a/evals/ask_user.eval.ts b/evals/ask_user.eval.ts new file mode 100644 index 0000000000..6495cb3f22 --- /dev/null +++ b/evals/ask_user.eval.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { appEvalTest, AppEvalCase } from './app-test-helper.js'; +import { EvalPolicy } from './test-helper.js'; + +function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) { + return appEvalTest(policy, { + ...evalCase, + configOverrides: { + ...evalCase.configOverrides, + general: { + ...evalCase.configOverrides?.general, + approvalMode: 'default', + enableAutoUpdate: false, + enableAutoUpdateNotification: false, + }, + }, + files: { + ...evalCase.files, + }, + }); +} + +describe('ask_user', () => { + askUserEvalTest('USUALLY_PASSES', { + name: 'Agent uses AskUser tool to present multiple choice options', + prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`, + setup: async (rig) => { + rig.setBreakpoint(['ask_user']); + }, + assert: async (rig) => { + const confirmation = await rig.waitForPendingConfirmation('ask_user'); + expect( + confirmation, + 'Expected a pending confirmation for ask_user tool', + ).toBeDefined(); + }, + }); + + askUserEvalTest('USUALLY_PASSES', { + name: 'Agent uses AskUser tool to clarify ambiguous requirements', + files: { + 'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }), + }, + prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`, + setup: async (rig) => { + rig.setBreakpoint(['ask_user']); + }, + assert: async (rig) => { + const confirmation = await rig.waitForPendingConfirmation('ask_user'); + expect( + confirmation, + 'Expected a pending confirmation for ask_user tool', + ).toBeDefined(); + }, + }); + + askUserEvalTest('USUALLY_PASSES', { + name: 'Agent uses AskUser tool before performing significant ambiguous rework', + files: { + 'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";', + 'packages/core/src/util.ts': '// util\nexport function help() {}', + 'packages/core/package.json': JSON.stringify({ + name: '@google/gemini-cli-core', + }), + 'README.md': '# Gemini CLI', + }, + prompt: `I want to completely rewrite the core package to support the upcoming V2 architecture, but I haven't decided what that looks like yet. We need to figure out the requirements first. Can you ask me some questions to help nail down the design?`, + setup: async (rig) => { + rig.setBreakpoint(['enter_plan_mode', 'ask_user']); + }, + assert: async (rig) => { + // It might call enter_plan_mode first. + let confirmation = await rig.waitForPendingConfirmation([ + 'enter_plan_mode', + 'ask_user', + ]); + expect(confirmation, 'Expected a tool call confirmation').toBeDefined(); + + if (confirmation?.name === 'enter_plan_mode') { + rig.acceptConfirmation('enter_plan_mode'); + confirmation = await rig.waitForPendingConfirmation('ask_user'); + } + + expect( + confirmation?.toolName, + 'Expected ask_user to be called to clarify the significant rework', + ).toBe('ask_user'); + }, + }); + + // --- Regression Tests for Recent Fixes --- + + // Regression test for issue #20177: Ensure the agent does not use \`ask_user\` to + // confirm shell commands. Fixed via prompt refinements and tool definition + // updates to clarify that shell command confirmation is handled by the UI. + // See fix: https://github.com/google-gemini/gemini-cli/pull/20504 + askUserEvalTest('USUALLY_PASSES', { + name: 'Agent does NOT use AskUser to confirm shell commands', + files: { + 'package.json': JSON.stringify({ + scripts: { build: 'echo building' }, + }), + }, + prompt: `Run 'npm run build' in the current directory.`, + setup: async (rig) => { + rig.setBreakpoint(['run_shell_command', 'ask_user']); + }, + assert: async (rig) => { + const confirmation = await rig.waitForPendingConfirmation([ + 'run_shell_command', + 'ask_user', + ]); + + expect( + confirmation, + 'Expected a pending confirmation for a tool', + ).toBeDefined(); + + expect( + confirmation?.toolName, + 'ask_user should not be called to confirm shell commands', + ).toBe('run_shell_command'); + }, + }); +}); diff --git a/evals/cli_help_delegation.eval.ts b/evals/cli_help_delegation.eval.ts new file mode 100644 index 0000000000..8be3bf1c51 --- /dev/null +++ b/evals/cli_help_delegation.eval.ts @@ -0,0 +1,25 @@ +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +describe('CliHelpAgent Delegation', () => { + evalTest('USUALLY_PASSES', { + name: 'should delegate to cli_help agent for subagent creation questions', + params: { + settings: { + experimental: { + enableAgents: true, + }, + }, + }, + prompt: 'Help me create a subagent in this project', + timeout: 60000, + assert: async (rig, _result) => { + const toolLogs = rig.readToolLogs(); + const toolCallIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'cli_help', + ); + expect(toolCallIndex).toBeGreaterThan(-1); + expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns + }, + }); +}); diff --git a/evals/concurrency-safety.eval.ts b/evals/concurrency-safety.eval.ts new file mode 100644 index 0000000000..f2f9e24be9 --- /dev/null +++ b/evals/concurrency-safety.eval.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +const MUTATION_AGENT_DEFINITION = `--- +name: mutation-agent +description: An agent that modifies the workspace (writes, deletes, git operations, etc). +max_turns: 1 +tools: + - write_file +--- + +You are the mutation agent. Do the mutation requested. +`; + +describe('concurrency safety eval test cases', () => { + evalTest('USUALLY_PASSES', { + name: 'mutation agents are run in parallel when explicitly requested', + params: { + settings: { + experimental: { + enableAgents: true, + }, + }, + }, + prompt: + 'Update A.txt to say "A" and update B.txt to say "B". Delegate these tasks to two separate mutation-agent subagents. You MUST run these subagents in parallel at the same time.', + files: { + '.gemini/agents/mutation-agent.md': MUTATION_AGENT_DEFINITION, + }, + assert: async (rig) => { + const logs = rig.readToolLogs(); + const mutationCalls = logs.filter( + (log) => log.toolRequest?.name === 'mutation-agent', + ); + + expect( + mutationCalls.length, + 'Agent should have called the mutation-agent at least twice', + ).toBeGreaterThanOrEqual(2); + + const firstPromptId = mutationCalls[0].toolRequest.prompt_id; + const secondPromptId = mutationCalls[1].toolRequest.prompt_id; + + expect( + firstPromptId, + 'mutation agents should be called in parallel (same turn / prompt_ids) when explicitly requested', + ).toEqual(secondPromptId); + }, + }); +}); diff --git a/evals/generalist_delegation.eval.ts b/evals/generalist_delegation.eval.ts index 7e6358ae1f..81252880eb 100644 --- a/evals/generalist_delegation.eval.ts +++ b/evals/generalist_delegation.eval.ts @@ -21,7 +21,6 @@ describe('generalist_delegation', () => { experimental: { enableAgents: true, }, - excludeTools: ['run_shell_command'], }, files: { 'file1.ts': 'console.log("no semi")', @@ -65,7 +64,6 @@ describe('generalist_delegation', () => { experimental: { enableAgents: true, }, - excludeTools: ['run_shell_command'], }, files: { 'src/a.ts': 'export const a = 1;', @@ -106,7 +104,6 @@ describe('generalist_delegation', () => { experimental: { enableAgents: true, }, - excludeTools: ['run_shell_command'], }, files: { 'README.md': 'This is a proyect.', @@ -141,7 +138,6 @@ describe('generalist_delegation', () => { experimental: { enableAgents: true, }, - excludeTools: ['run_shell_command'], }, files: { 'src/VERSION': '1.2.3', diff --git a/evals/gitRepo.eval.ts b/evals/gitRepo.eval.ts index ea51d196ac..6415b9c20d 100644 --- a/evals/gitRepo.eval.ts +++ b/evals/gitRepo.eval.ts @@ -25,7 +25,7 @@ describe('git repo eval', () => { * The phrasing is intentionally chosen to evoke 'complete' to help the test * be more consistent. */ - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should not git add commit changes unprompted', prompt: 'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests', diff --git a/evals/hierarchical_memory.eval.ts b/evals/hierarchical_memory.eval.ts index 71f9cc3e43..dd4f8fbbd1 100644 --- a/evals/hierarchical_memory.eval.ts +++ b/evals/hierarchical_memory.eval.ts @@ -11,7 +11,7 @@ import { assertModelHasOutput } from '../integration-tests/test-helper.js'; describe('Hierarchical Memory', () => { const conflictResolutionTest = 'Agent follows hierarchy for contradictory instructions'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: conflictResolutionTest, params: { settings: { @@ -86,7 +86,7 @@ Provide the answer as an XML block like this: }); const extensionVsGlobalTest = 'Extension memory wins over Global memory'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: extensionVsGlobalTest, params: { settings: { diff --git a/evals/model_steering.eval.ts b/evals/model_steering.eval.ts index 87109c1225..2cb87edcc2 100644 --- a/evals/model_steering.eval.ts +++ b/evals/model_steering.eval.ts @@ -12,10 +12,9 @@ import { appEvalTest } from './app-test-helper.js'; import { PolicyDecision } from '@google/gemini-cli-core'; describe('Model Steering Behavioral Evals', () => { - appEvalTest('ALWAYS_PASSES', { + appEvalTest('USUALLY_PASSES', { name: 'Corrective Hint: Model switches task based on hint during tool turn', configOverrides: { - excludeTools: ['run_shell_command', 'ls', 'google_web_search'], modelSteering: true, }, files: { @@ -52,10 +51,9 @@ describe('Model Steering Behavioral Evals', () => { }, }); - appEvalTest('ALWAYS_PASSES', { + appEvalTest('USUALLY_PASSES', { name: 'Suggestive Hint: Model incorporates user guidance mid-stream', configOverrides: { - excludeTools: ['run_shell_command', 'ls', 'google_web_search'], modelSteering: true, }, files: {}, diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts index ff70a2b4ad..a37e5f91b4 100644 --- a/evals/plan_mode.eval.ts +++ b/evals/plan_mode.eval.ts @@ -18,7 +18,19 @@ describe('plan_mode', () => { experimental: { plan: true }, }; - evalTest('USUALLY_PASSES', { + const getWriteTargets = (logs: any[]) => + logs + .filter((log) => ['write_file', 'replace'].includes(log.toolRequest.name)) + .map((log) => { + try { + return JSON.parse(log.toolRequest.args).file_path as string; + } catch { + return ''; + } + }) + .filter(Boolean); + + evalTest('ALWAYS_PASSES', { name: 'should refuse file modification when in plan mode', approvalMode: ApprovalMode.PLAN, params: { @@ -32,32 +44,28 @@ describe('plan_mode', () => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); - const writeTargets = toolLogs - .filter((log) => - ['write_file', 'replace'].includes(log.toolRequest.name), - ) - .map((log) => { - try { - return JSON.parse(log.toolRequest.args).file_path; - } catch { - return null; - } - }); + const exitPlanIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'exit_plan_mode', + ); + + const writeTargetsBeforeExitPlan = getWriteTargets( + toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), + ); expect( - writeTargets, + writeTargetsBeforeExitPlan, 'Should not attempt to modify README.md in plan mode', ).not.toContain('README.md'); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i], - testName: `${TEST_PREFIX}should refuse file modification`, + testName: `${TEST_PREFIX}should refuse file modification in plan mode`, }); }, }); - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should refuse saving new documentation to the repo when in plan mode', approvalMode: ApprovalMode.PLAN, params: { @@ -69,24 +77,20 @@ describe('plan_mode', () => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); - const writeTargets = toolLogs - .filter((log) => - ['write_file', 'replace'].includes(log.toolRequest.name), - ) - .map((log) => { - try { - return JSON.parse(log.toolRequest.args).file_path; - } catch { - return null; - } - }); + const exitPlanIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'exit_plan_mode', + ); + + const writeTargetsBeforeExit = getWriteTargets( + toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), + ); // It should NOT write to the docs folder or any other repo path - const hasRepoWrite = writeTargets.some( + const hasRepoWriteBeforeExit = writeTargetsBeforeExit.some( (path) => path && !path.includes('/plans/'), ); expect( - hasRepoWrite, + hasRepoWriteBeforeExit, 'Should not attempt to create files in the repository while in plan mode', ).toBe(false); @@ -166,4 +170,65 @@ describe('plan_mode', () => { assertModelHasOutput(result); }, }); + + evalTest('USUALLY_PASSES', { + name: 'should create a plan in plan mode and implement it for a refactoring task', + params: { + settings, + }, + files: { + 'src/mathUtils.ts': + 'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;', + 'src/main.ts': + 'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));', + }, + prompt: + 'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.', + assert: async (rig, result) => { + const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode'); + expect( + enterPlanCalled, + 'Expected enter_plan_mode tool to be called', + ).toBe(true); + + const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode'); + expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe( + true, + ); + + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + // Check if plan was written + const planWrite = toolLogs.find( + (log) => + log.toolRequest.name === 'write_file' && + log.toolRequest.args.includes('/plans/'), + ); + expect( + planWrite, + 'Expected a plan file to be written in the plans directory', + ).toBeDefined(); + + // Check for implementation files + const newFileWrite = toolLogs.find( + (log) => + log.toolRequest.name === 'write_file' && + log.toolRequest.args.includes('src/basicMath.ts'), + ); + expect( + newFileWrite, + 'Expected src/basicMath.ts to be created', + ).toBeDefined(); + + const mainUpdate = toolLogs.find( + (log) => + ['write_file', 'replace'].includes(log.toolRequest.name) && + log.toolRequest.args.includes('src/main.ts'), + ); + expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined(); + + assertModelHasOutput(result); + }, + }); }); diff --git a/evals/save_memory.eval.ts b/evals/save_memory.eval.ts index 11f0c932d9..8be7b39e35 100644 --- a/evals/save_memory.eval.ts +++ b/evals/save_memory.eval.ts @@ -14,11 +14,9 @@ import { describe('save_memory', () => { const TEST_PREFIX = 'Save memory test: '; const rememberingFavoriteColor = "Agent remembers user's favorite color"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingFavoriteColor, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `remember that my favorite color is blue. what is my favorite color? tell me that and surround it with $ symbol`, @@ -38,9 +36,7 @@ describe('save_memory', () => { const rememberingCommandRestrictions = 'Agent remembers command restrictions'; evalTest('USUALLY_PASSES', { name: rememberingCommandRestrictions, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `I don't want you to ever run npm commands.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -59,9 +55,7 @@ describe('save_memory', () => { const rememberingWorkflow = 'Agent remembers workflow preferences'; evalTest('USUALLY_PASSES', { name: rememberingWorkflow, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `I want you to always lint after building.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -79,11 +73,9 @@ describe('save_memory', () => { const ignoringTemporaryInformation = 'Agent ignores temporary conversation details'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: ignoringTemporaryInformation, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `I'm going to get a coffee.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); @@ -104,11 +96,9 @@ describe('save_memory', () => { }); const rememberingPetName = "Agent remembers user's pet's name"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingPetName, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `Please remember that my dog's name is Buddy.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -125,11 +115,9 @@ describe('save_memory', () => { }); const rememberingCommandAlias = 'Agent remembers custom command aliases'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingCommandAlias, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `When I say 'start server', you should run 'npm run dev'.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -149,18 +137,6 @@ describe('save_memory', () => { "Agent ignores workspace's database schema location"; evalTest('USUALLY_PASSES', { name: ignoringDbSchemaLocation, - params: { - settings: { - tools: { - core: [ - 'save_memory', - 'list_directory', - 'read_file', - 'run_shell_command', - ], - }, - }, - }, prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); @@ -178,11 +154,9 @@ describe('save_memory', () => { const rememberingCodingStyle = "Agent remembers user's coding style preference"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingCodingStyle, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `I prefer to use tabs instead of spaces for indentation.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -202,18 +176,6 @@ describe('save_memory', () => { 'Agent ignores workspace build artifact location'; evalTest('USUALLY_PASSES', { name: ignoringBuildArtifactLocation, - params: { - settings: { - tools: { - core: [ - 'save_memory', - 'list_directory', - 'read_file', - 'run_shell_command', - ], - }, - }, - }, prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); @@ -232,18 +194,6 @@ describe('save_memory', () => { const ignoringMainEntryPoint = "Agent ignores workspace's main entry point"; evalTest('USUALLY_PASSES', { name: ignoringMainEntryPoint, - params: { - settings: { - tools: { - core: [ - 'save_memory', - 'list_directory', - 'read_file', - 'run_shell_command', - ], - }, - }, - }, prompt: `The main entry point for this workspace is \`src/index.js\`.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); @@ -260,11 +210,9 @@ describe('save_memory', () => { }); const rememberingBirthday = "Agent remembers user's birthday"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingBirthday, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `My birthday is on June 15th.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); diff --git a/evals/shell-efficiency.eval.ts b/evals/shell-efficiency.eval.ts index fbb8cc133e..dc555d5298 100644 --- a/evals/shell-efficiency.eval.ts +++ b/evals/shell-efficiency.eval.ts @@ -72,7 +72,7 @@ describe('Shell Efficiency', () => { }, }); - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled', params: { settings: { diff --git a/evals/test-helper.ts b/evals/test-helper.ts index 44c538c197..66143ddfb6 100644 --- a/evals/test-helper.ts +++ b/evals/test-helper.ts @@ -112,6 +112,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { // commands. execSync('git config core.editor "true"', execOptions); execSync('git config core.pager "cat"', execOptions); + execSync('git config commit.gpgsign false', execOptions); execSync('git add .', execOptions); execSync('git commit --allow-empty -m "Initial commit"', execOptions); } @@ -196,9 +197,25 @@ export function symlinkNodeModules(testDir: string) { } } +/** + * Settings that are forbidden in evals. Evals should never restrict which + * tools are available — they must test against the full, default tool set + * to ensure realistic behavior. + */ +interface ForbiddenToolSettings { + tools?: { + /** Restricting core tools in evals is forbidden. */ + core?: never; + [key: string]: unknown; + }; +} + export interface EvalCase { name: string; - params?: Record; + params?: { + settings?: ForbiddenToolSettings & Record; + [key: string]: unknown; + }; prompt: string; timeout?: number; files?: Record; diff --git a/evals/tracker.eval.ts b/evals/tracker.eval.ts new file mode 100644 index 0000000000..7afb41dbec --- /dev/null +++ b/evals/tracker.eval.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { + TRACKER_CREATE_TASK_TOOL_NAME, + TRACKER_UPDATE_TASK_TOOL_NAME, +} from '@google/gemini-cli-core'; +import { evalTest, assertModelHasOutput } from './test-helper.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +const FILES = { + 'package.json': JSON.stringify({ + name: 'test-project', + version: '1.0.0', + scripts: { test: 'echo "All tests passed!"' }, + }), + 'src/login.js': + 'function login(username, password) {\n if (!username) throw new Error("Missing username");\n // BUG: missing password check\n return true;\n}', +} as const; + +describe('tracker_mode', () => { + evalTest('USUALLY_PASSES', { + name: 'should manage tasks in the tracker when explicitly requested during a bug fix', + params: { + settings: { experimental: { taskTracker: true } }, + }, + files: FILES, + prompt: + 'We have a bug in src/login.js: the password check is missing. First, create a task in the tracker to fix it. Then fix the bug, and mark the task as closed.', + assert: async (rig, result) => { + const wasCreateCalled = await rig.waitForToolCall( + TRACKER_CREATE_TASK_TOOL_NAME, + ); + expect( + wasCreateCalled, + 'Expected tracker_create_task tool to be called', + ).toBe(true); + + const toolLogs = rig.readToolLogs(); + const createCall = toolLogs.find( + (log) => log.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME, + ); + expect(createCall).toBeDefined(); + const args = JSON.parse(createCall!.toolRequest.args); + expect( + (args.title?.toLowerCase() ?? '') + + (args.description?.toLowerCase() ?? ''), + ).toContain('login'); + + const wasUpdateCalled = await rig.waitForToolCall( + TRACKER_UPDATE_TASK_TOOL_NAME, + ); + expect( + wasUpdateCalled, + 'Expected tracker_update_task tool to be called', + ).toBe(true); + + const updateCall = toolLogs.find( + (log) => log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME, + ); + expect(updateCall).toBeDefined(); + const updateArgs = JSON.parse(updateCall!.toolRequest.args); + expect(updateArgs.status).toBe('closed'); + + const loginContent = fs.readFileSync( + path.join(rig.testDir!, 'src/login.js'), + 'utf-8', + ); + expect(loginContent).not.toContain('// BUG: missing password check'); + + assertModelHasOutput(result); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should implicitly create tasks when asked to build a feature plan', + params: { + settings: { experimental: { taskTracker: true } }, + }, + files: FILES, + prompt: + 'I need to build a complex new feature for user authentication in our project. Create a detailed implementation plan and organize the work into bite-sized chunks. Do not actually implement the code yet, just plan it.', + assert: async (rig, result) => { + // The model should proactively use tracker_create_task to organize the work + const wasToolCalled = await rig.waitForToolCall( + TRACKER_CREATE_TASK_TOOL_NAME, + ); + expect( + wasToolCalled, + 'Expected tracker_create_task to be called implicitly to organize plan', + ).toBe(true); + + const toolLogs = rig.readToolLogs(); + const createCalls = toolLogs.filter( + (log) => log.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME, + ); + + // We expect it to create at least one task for authentication, likely more. + expect(createCalls.length).toBeGreaterThan(0); + + // Verify it didn't write any code since we asked it to just plan + const loginContent = fs.readFileSync( + path.join(rig.testDir!, 'src/login.js'), + 'utf-8', + ); + expect(loginContent).toContain('// BUG: missing password check'); + + assertModelHasOutput(result); + }, + }); +}); diff --git a/evals/validation_fidelity.eval.ts b/evals/validation_fidelity.eval.ts index d8f571773d..8cfb4f6626 100644 --- a/evals/validation_fidelity.eval.ts +++ b/evals/validation_fidelity.eval.ts @@ -8,7 +8,7 @@ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('validation_fidelity', () => { - evalTest('ALWAYS_PASSES', { + evalTest('USUALLY_PASSES', { name: 'should perform exhaustive validation autonomously when guided by system instructions', files: { 'src/types.ts': ` diff --git a/evals/vitest.config.ts b/evals/vitest.config.ts index 50733a999c..3231f31a10 100644 --- a/evals/vitest.config.ts +++ b/evals/vitest.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ }, test: { testTimeout: 300000, // 5 minutes + // Retry in CI but not nightly to avoid blocking on API error. + retry: process.env['VITEST_RETRY'] + ? parseInt(process.env['VITEST_RETRY'], 10) + : 3, reporters: ['default', 'json'], outputFile: { json: 'evals/logs/report.json', diff --git a/integration-tests/acp-env-auth.test.ts b/integration-tests/acp-env-auth.test.ts index c83dbafce5..65f8adbf22 100644 --- a/integration-tests/acp-env-auth.test.ts +++ b/integration-tests/acp-env-auth.test.ts @@ -55,7 +55,7 @@ describe.skip('ACP Environment and Auth', () => { const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js'); - child = spawn('node', [bundlePath, '--experimental-acp'], { + child = spawn('node', [bundlePath, '--acp'], { cwd: rig.homeDir!, stdio: ['pipe', 'pipe', 'inherit'], env: { @@ -120,7 +120,7 @@ describe.skip('ACP Environment and Auth', () => { const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js'); - child = spawn('node', [bundlePath, '--experimental-acp'], { + child = spawn('node', [bundlePath, '--acp'], { cwd: rig.homeDir!, stdio: ['pipe', 'pipe', 'inherit'], env: { diff --git a/integration-tests/acp-telemetry.test.ts b/integration-tests/acp-telemetry.test.ts index 970239de9e..f883b977bf 100644 --- a/integration-tests/acp-telemetry.test.ts +++ b/integration-tests/acp-telemetry.test.ts @@ -58,7 +58,7 @@ describe('ACP telemetry', () => { 'node', [ bundlePath, - '--experimental-acp', + '--acp', '--fake-responses', join(rig.testDir!, 'fake-responses.json'), ], @@ -72,7 +72,6 @@ describe('ACP telemetry', () => { GEMINI_TELEMETRY_ENABLED: 'true', GEMINI_TELEMETRY_TARGET: 'local', GEMINI_TELEMETRY_OUTFILE: telemetryPath, - // GEMINI_DEV_TRACING not set: fake responses aren't instrumented for spans }, }, ); diff --git a/integration-tests/api-resilience.responses b/integration-tests/api-resilience.responses new file mode 100644 index 0000000000..d30d29906e --- /dev/null +++ b/integration-tests/api-resilience.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0}],"finishReason":"STOP"}]} diff --git a/integration-tests/api-resilience.test.ts b/integration-tests/api-resilience.test.ts new file mode 100644 index 0000000000..870adf701a --- /dev/null +++ b/integration-tests/api-resilience.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +describe('API Resilience E2E', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should not crash when receiving metadata-only chunks in a stream', async () => { + await rig.setup('api-resilience-metadata-only', { + fakeResponsesPath: join( + dirname(fileURLToPath(import.meta.url)), + 'api-resilience.responses', + ), + settings: { + planSettings: { modelRouting: false }, + }, + }); + + // Run the CLI with a simple prompt. + // The fake responses will provide a stream with a metadata-only chunk in the middle. + // We use gemini-3-pro-preview to minimize internal service calls. + const result = await rig.run({ + args: ['hi', '--model', 'gemini-3-pro-preview'], + }); + + // Verify the output contains text from the normal chunks. + // If the CLI crashed on the metadata chunk, rig.run would throw. + expect(result).toContain('Part 1.'); + expect(result).toContain('Part 2.'); + + // Verify telemetry event for the prompt was still generated + const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt'); + expect(hasUserPromptEvent).toBe(true); + }); +}); diff --git a/integration-tests/browser-agent.cleanup.responses b/integration-tests/browser-agent.cleanup.responses new file mode 100644 index 0000000000..9cf7a7b356 --- /dev/null +++ b/integration-tests/browser-agent.cleanup.responses @@ -0,0 +1,4 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The task is complete. The page title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":20,"totalTokenCount":320}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":400,"candidatesTokenCount":5,"totalTokenCount":405}}]} diff --git a/integration-tests/browser-agent.confirmation.responses b/integration-tests/browser-agent.confirmation.responses new file mode 100644 index 0000000000..4f645c6531 --- /dev/null +++ b/integration-tests/browser-agent.confirmation.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":"hello"}}},{"text":"I've successfully written \"hello\" to test.txt. The file has been created with the specified content."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} diff --git a/integration-tests/browser-agent.interaction.responses b/integration-tests/browser-agent.interaction.responses new file mode 100644 index 0000000000..98474d6b59 --- /dev/null +++ b/integration-tests/browser-agent.interaction.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and analyze the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Go to https://example.com and find all links on the page, then describe them"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"After analyzing https://example.com, I found the following links:\n\n1. **\"More information...\"** - This is the main link on the page that points to the IANA (Internet Assigned Numbers Authority) website for more details about reserved domains.\n\nThe page is quite minimal with just this single informational link, which is typical for example domains used in documentation."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":70,"totalTokenCount":270}}]} diff --git a/integration-tests/browser-agent.navigate-snapshot.responses b/integration-tests/browser-agent.navigate-snapshot.responses new file mode 100644 index 0000000000..481520234d --- /dev/null +++ b/integration-tests/browser-agent.navigate-snapshot.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you open https://example.com and analyze the page. Let me use the browser agent to navigate and capture the page information."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and capture the accessibility tree to get the page title and main content"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Based on the browser analysis of https://example.com:\n\n**Page Title**: Example Domain\n\n**Main Content**: The page contains a simple heading \"Example Domain\" and explanatory text stating \"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\"\n\nThe page has a clean, minimal layout typical of placeholder domains used for documentation and examples."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]} diff --git a/integration-tests/browser-agent.screenshot.responses b/integration-tests/browser-agent.screenshot.responses new file mode 100644 index 0000000000..c9abaa4268 --- /dev/null +++ b/integration-tests/browser-agent.screenshot.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and take a screenshot for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and take a screenshot of the page"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully navigated to https://example.com and captured a screenshot. The page shows the familiar \"Example Domain\" header with explanatory text below it. The screenshot captures the clean, minimal layout of this demonstration website."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]} diff --git a/integration-tests/browser-agent.sequential.responses b/integration-tests/browser-agent.sequential.responses new file mode 100644 index 0000000000..74732998aa --- /dev/null +++ b/integration-tests/browser-agent.sequential.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you navigate to https://example.com and perform both operations - capturing the accessibility tree and taking a screenshot."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":45,"totalTokenCount":145}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully completed both operations on https://example.com:\n\n**Accessibility Tree Snapshot**: The page has a clear structure with the main heading \"Example Domain\" and descriptive text about the domain's purpose for documentation examples.\n\n**Screenshot**: Captured a visual representation of the page showing the clean, minimal layout with the heading and explanatory text.\n\nBoth the accessibility data and visual screenshot confirm this is the standard example domain page used for documentation purposes."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]} diff --git a/integration-tests/browser-agent.test.ts b/integration-tests/browser-agent.test.ts new file mode 100644 index 0000000000..f9f07d4c9e --- /dev/null +++ b/integration-tests/browser-agent.test.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Integration tests for the browser agent. + * + * These tests verify the complete end-to-end flow from CLI prompt through + * browser_agent delegation to MCP/Chrome DevTools and back. Unlike the unit + * tests in packages/core/src/agents/browser/ which mock all MCP components, + * these tests launch real Chrome instances in headless mode. + * + * Tests are skipped on systems without Chrome/Chromium installed. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig, assertModelHasOutput } from './test-helper.js'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const chromeAvailable = (() => { + try { + if (process.platform === 'darwin') { + execSync( + 'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"', + { + stdio: 'ignore', + }, + ); + } else if (process.platform === 'linux') { + execSync( + 'which google-chrome || which chromium-browser || which chromium', + { stdio: 'ignore' }, + ); + } else if (process.platform === 'win32') { + // Check standard Windows installation paths using Node.js fs + const chromePaths = [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + `${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`, + ]; + const found = chromePaths.some((p) => existsSync(p)); + if (!found) { + // Fall back to PATH check + execSync('where chrome || where chromium', { stdio: 'ignore' }); + } + } else { + return false; + } + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!chromeAvailable)('browser-agent', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + it('should navigate to a page and capture accessibility tree', async () => { + rig.setup('browser-navigate-and-snapshot', { + fakeResponsesPath: join( + __dirname, + 'browser-agent.navigate-snapshot.responses', + ), + settings: { + agents: { + browser_agent: { + headless: true, + sessionMode: 'isolated', + }, + }, + }, + }); + + const result = await rig.run({ + args: 'Open https://example.com in the browser and tell me the page title and main content.', + }); + + assertModelHasOutput(result); + + const toolLogs = rig.readToolLogs(); + const browserAgentCall = toolLogs.find( + (t) => t.toolRequest.name === 'browser_agent', + ); + expect( + browserAgentCall, + 'Expected browser_agent to be called', + ).toBeDefined(); + }); + + it('should take screenshots of web pages', async () => { + rig.setup('browser-screenshot', { + fakeResponsesPath: join(__dirname, 'browser-agent.screenshot.responses'), + settings: { + agents: { + browser_agent: { + headless: true, + sessionMode: 'isolated', + }, + }, + }, + }); + + const result = await rig.run({ + args: 'Navigate to https://example.com and take a screenshot.', + }); + + const toolLogs = rig.readToolLogs(); + const browserCalls = toolLogs.filter( + (t) => t.toolRequest.name === 'browser_agent', + ); + expect(browserCalls.length).toBeGreaterThan(0); + + assertModelHasOutput(result); + }); + + it('should interact with page elements', async () => { + rig.setup('browser-interaction', { + fakeResponsesPath: join(__dirname, 'browser-agent.interaction.responses'), + settings: { + agents: { + browser_agent: { + headless: true, + sessionMode: 'isolated', + }, + }, + }, + }); + + const result = await rig.run({ + args: 'Go to https://example.com, find any links on the page, and describe them.', + }); + + const toolLogs = rig.readToolLogs(); + const browserAgentCall = toolLogs.find( + (t) => t.toolRequest.name === 'browser_agent', + ); + expect( + browserAgentCall, + 'Expected browser_agent to be called', + ).toBeDefined(); + + assertModelHasOutput(result); + }); + + it('should clean up browser processes after completion', async () => { + rig.setup('browser-cleanup', { + fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'), + settings: { + agents: { + browser_agent: { + headless: true, + sessionMode: 'isolated', + }, + }, + }, + }); + + await rig.run({ + args: 'Open https://example.com in the browser and check the page title.', + }); + + // Test passes if we reach here, relying on Vitest's timeout mechanism + // to detect hanging browser processes. + }); + + it('should handle multiple browser operations in sequence', async () => { + rig.setup('browser-sequential', { + fakeResponsesPath: join(__dirname, 'browser-agent.sequential.responses'), + settings: { + agents: { + browser_agent: { + headless: true, + sessionMode: 'isolated', + }, + }, + }, + }); + + const result = await rig.run({ + args: 'Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot.', + }); + + const toolLogs = rig.readToolLogs(); + const browserCalls = toolLogs.filter( + (t) => t.toolRequest.name === 'browser_agent', + ); + expect(browserCalls.length).toBeGreaterThan(0); + + // Should successfully complete all operations + assertModelHasOutput(result); + }); + + it('should handle tool confirmation for write_file without crashing', async () => { + rig.setup('tool-confirmation', { + fakeResponsesPath: join( + __dirname, + 'browser-agent.confirmation.responses', + ), + settings: { + agents: { + browser_agent: { + headless: true, + sessionMode: 'isolated', + }, + }, + }, + }); + + const run = await rig.runInteractive({ approvalMode: 'default' }); + + await run.type('Write hello to test.txt'); + await run.type('\r'); + + await run.expectText('Allow', 15000); + + await run.type('y'); + await run.type('\r'); + + await run.expectText('successfully written', 15000); + }); +}); diff --git a/integration-tests/browser-policy.responses b/integration-tests/browser-policy.responses new file mode 100644 index 0000000000..23d14e0cb3 --- /dev/null +++ b/integration-tests/browser-policy.responses @@ -0,0 +1,5 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you with that."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and check if there is a heading"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"new_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"success":true,"summary":"SUCCESS_POLICY_TEST_COMPLETED"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Task completed successfully. The page has the heading \"Example Domain\"."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]} diff --git a/integration-tests/browser-policy.test.ts b/integration-tests/browser-policy.test.ts new file mode 100644 index 0000000000..f533cb3f5e --- /dev/null +++ b/integration-tests/browser-policy.test.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig, poll } from './test-helper.js'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execSync } from 'node:child_process'; +import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; +import stripAnsi from 'strip-ansi'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const chromeAvailable = (() => { + try { + if (process.platform === 'darwin') { + execSync( + 'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"', + { + stdio: 'ignore', + }, + ); + } else if (process.platform === 'linux') { + execSync( + 'which google-chrome || which chromium-browser || which chromium', + { stdio: 'ignore' }, + ); + } else if (process.platform === 'win32') { + const chromePaths = [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + `${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`, + ]; + const found = chromePaths.some((p) => existsSync(p)); + if (!found) { + execSync('where chrome || where chromium', { stdio: 'ignore' }); + } + } else { + return false; + } + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!chromeAvailable)('browser-policy', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should skip confirmation when "Allow all server tools for this session" is chosen', async () => { + rig.setup('browser-policy-skip-confirmation', { + fakeResponsesPath: join(__dirname, 'browser-policy.responses'), + settings: { + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: true, + sessionMode: 'isolated', + allowedDomains: ['example.com'], + }, + }, + }, + }); + + // Manually trust the folder to avoid the dialog and enable option 3 + const geminiDir = join(rig.homeDir!, '.gemini'); + mkdirSync(geminiDir, { recursive: true }); + + // Write to trustedFolders.json + const trustedFoldersPath = join(geminiDir, 'trustedFolders.json'); + const trustedFolders = { + [rig.testDir!]: 'TRUST_FOLDER', + }; + writeFileSync(trustedFoldersPath, JSON.stringify(trustedFolders, null, 2)); + + // Force confirmation for browser agent. + // NOTE: We don't force confirm browser tools here because "Allow all server tools" + // adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by + // a rule in the user tier (4.x) like the one from this TOML. + // By removing the explicit mcp rule, the first MCP tool will still prompt + // due to default approvalMode = 'default', and then "Allow all" will correctly + // bypass subsequent tools. + const policyFile = join(rig.testDir!, 'force-confirm.toml'); + writeFileSync( + policyFile, + ` +[[rule]] +name = "Force confirm browser_agent" +toolName = "browser_agent" +decision = "ask_user" +priority = 200 +`, + ); + + // Update settings.json in both project and home directories to point to the policy file + for (const baseDir of [rig.testDir!, rig.homeDir!]) { + const settingsPath = join(baseDir, '.gemini', 'settings.json'); + if (existsSync(settingsPath)) { + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + settings.policyPaths = [policyFile]; + // Ensure folder trust is enabled + settings.security = settings.security || {}; + settings.security.folderTrust = settings.security.folderTrust || {}; + settings.security.folderTrust.enabled = true; + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } + } + + const run = await rig.runInteractive({ + approvalMode: 'default', + env: { + GEMINI_CLI_INTEGRATION_TEST: 'true', + }, + }); + + await run.sendKeys( + 'Open https://example.com and check if there is a heading\r', + ); + await run.sendKeys('\r'); + + // Handle confirmations. + // 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once) + await poll( + () => stripAnsi(run.output).toLowerCase().includes('action required'), + 60000, + 1000, + ); + await run.sendKeys('1\r'); + await new Promise((r) => setTimeout(r, 2000)); + + // Handle privacy notice + await poll( + () => stripAnsi(run.output).toLowerCase().includes('privacy notice'), + 5000, + 100, + ); + await run.sendKeys('1\r'); + await new Promise((r) => setTimeout(r, 5000)); + + // new_page (MCP tool, should have 4 options, use option 3: Allow all server tools) + await poll( + () => { + const stripped = stripAnsi(run.output).toLowerCase(); + return ( + stripped.includes('new_page') && + stripped.includes('allow all server tools for this session') + ); + }, + 60000, + 1000, + ); + + // Select "Allow all server tools for this session" (option 3) + await run.sendKeys('3\r'); + await new Promise((r) => setTimeout(r, 30000)); + + const output = stripAnsi(run.output).toLowerCase(); + + expect(output).toContain('browser_agent'); + expect(output).toContain('completed successfully'); + }); + + it('should show the visible warning when browser agent starts in existing session mode', async () => { + rig.setup('browser-session-warning', { + fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'), + settings: { + general: { + enableAutoUpdateNotification: false, + }, + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + sessionMode: 'existing', + headless: true, + }, + }, + }, + }); + + const stdout = await rig.runCommand(['Open https://example.com'], { + env: { + GEMINI_API_KEY: 'fake-key', + GEMINI_TELEMETRY_DISABLED: 'true', + DEV: 'true', + }, + }); + + expect(stdout).toContain('saved logins will be visible'); + }); +}); diff --git a/integration-tests/deprecation-warnings.test.ts b/integration-tests/deprecation-warnings.test.ts new file mode 100644 index 0000000000..5b040f4623 --- /dev/null +++ b/integration-tests/deprecation-warnings.test.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; + +/** + * integration test to ensure no node.js deprecation warnings are emitted. + * must run for all supported node versions as warnings may vary by version. + */ +describe('deprecation-warnings', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + it.each([ + { command: '--version', description: 'running --version' }, + { command: '--help', description: 'running with --help' }, + ])( + 'should not emit any deprecation warnings when $description', + async ({ command, description }) => { + await rig.setup( + `should not emit any deprecation warnings when ${description}`, + ); + + const { stderr, exitCode } = await rig.runWithStreams([command]); + + // node.js deprecation warnings: (node:12345) [DEP0040] DeprecationWarning: ... + const deprecationWarningPattern = /\[DEP\d+\].*DeprecationWarning/i; + const hasDeprecationWarning = deprecationWarningPattern.test(stderr); + + if (hasDeprecationWarning) { + const deprecationMatches = stderr.match( + /\[DEP\d+\].*DeprecationWarning:.*/gi, + ); + const warnings = deprecationMatches + ? deprecationMatches.map((m) => m.trim()).join('\n') + : 'Unknown deprecation warning format'; + + throw new Error( + `Deprecation warnings detected in CLI output:\n${warnings}\n\n` + + `Full stderr:\n${stderr}\n\n` + + `This test ensures no deprecated Node.js modules are used. ` + + `Please update dependencies to use non-deprecated alternatives.`, + ); + } + + // only check exit code if no deprecation warnings found + if (exitCode !== 0) { + throw new Error( + `CLI exited with code ${exitCode} (expected 0). This may indicate a setup issue.\n` + + `Stderr: ${stderr}`, + ); + } + }, + ); +}); diff --git a/integration-tests/extensions-install.test.ts b/integration-tests/extensions-install.test.ts index 9aceeb6564..90dbf1ab0d 100644 --- a/integration-tests/extensions-install.test.ts +++ b/integration-tests/extensions-install.test.ts @@ -42,11 +42,10 @@ describe('extension install', () => { const listResult = await rig.runCommand(['extensions', 'list']); expect(listResult).toContain('test-extension-install'); writeFileSync(testServerPath, extensionUpdate); - const updateResult = await rig.runCommand([ - 'extensions', - 'update', - `test-extension-install`, - ]); + const updateResult = await rig.runCommand( + ['extensions', 'update', `test-extension-install`], + { stdin: 'y\n' }, + ); expect(updateResult).toContain('0.0.2'); } finally { await rig.runCommand([ diff --git a/integration-tests/extensions-reload.test.ts b/integration-tests/extensions-reload.test.ts index 520076d7c6..9d451cedcf 100644 --- a/integration-tests/extensions-reload.test.ts +++ b/integration-tests/extensions-reload.test.ts @@ -104,7 +104,7 @@ describe('extension reloading', () => { return ( output.includes( 'test-server (from test-extension) - Ready (1 tool)', - ) && output.includes('- hello') + ) && output.includes('- mcp_test-server_hello') ); }, 30000, // 30s timeout @@ -148,7 +148,7 @@ describe('extension reloading', () => { return ( output.includes( 'test-server (from test-extension) - Ready (1 tool)', - ) && output.includes('- goodbye') + ) && output.includes('- mcp_test-server_goodbye') ); }, 30000, diff --git a/integration-tests/hooks-agent-flow.test.ts b/integration-tests/hooks-agent-flow.test.ts index 757c692366..b602737a39 100644 --- a/integration-tests/hooks-agent-flow.test.ts +++ b/integration-tests/hooks-agent-flow.test.ts @@ -165,14 +165,15 @@ describe('Hooks Agent Flow', () => { // BeforeModel hook to track message counts across LLM calls const messageCountFile = join(rig.testDir!, 'message-counts.json'); + const escapedPath = JSON.stringify(messageCountFile); const beforeModelScript = ` const fs = require('fs'); const input = JSON.parse(fs.readFileSync(0, 'utf-8')); const messageCount = input.llm_request?.contents?.length || 0; let counts = []; - try { counts = JSON.parse(fs.readFileSync(${JSON.stringify(messageCountFile)}, 'utf-8')); } catch (e) {} + try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {} counts.push(messageCount); - fs.writeFileSync(${JSON.stringify(messageCountFile)}, JSON.stringify(counts)); + fs.writeFileSync(${escapedPath}, JSON.stringify(counts)); console.log(JSON.stringify({ decision: 'allow' })); `; const beforeModelScriptPath = rig.createScript( @@ -181,14 +182,22 @@ describe('Hooks Agent Flow', () => { ); const afterAgentScript = ` - console.log(JSON.stringify({ - decision: 'block', - reason: 'Security policy triggered', - hookSpecificOutput: { - hookEventName: 'AfterAgent', - clearContext: true - } - })); + const fs = require('fs'); + const input = JSON.parse(fs.readFileSync(0, 'utf-8')); + if (input.stop_hook_active) { + // Retry turn: allow execution to proceed (breaks the loop) + console.log(JSON.stringify({ decision: 'allow' })); + } else { + // First call: block and clear context to trigger the retry + console.log(JSON.stringify({ + decision: 'block', + reason: 'Security policy triggered', + hookSpecificOutput: { + hookEventName: 'AfterAgent', + clearContext: true + } + })); + } `; const afterAgentScriptPath = rig.createScript( 'after_agent_clear.cjs', @@ -197,8 +206,10 @@ describe('Hooks Agent Flow', () => { rig.setup('should process clearContext in AfterAgent hook output', { settings: { - hooks: { + hooksConfig: { enabled: true, + }, + hooks: { BeforeModel: [ { hooks: [ diff --git a/integration-tests/hooks-system.after-agent.responses b/integration-tests/hooks-system.after-agent.responses index 1475070c3d..526c59362d 100644 --- a/integration-tests/hooks-system.after-agent.responses +++ b/integration-tests/hooks-system.after-agent.responses @@ -1,2 +1,3 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hi there!"}],"role":"model"},"finishReason":"STOP","index":0}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Clarification: I am a bot."}],"role":"model"},"finishReason":"STOP","index":0}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Security policy triggered"}],"role":"model"},"finishReason":"STOP","index":0}]}]} diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts index 479851957b..4fe63a3ab6 100644 --- a/integration-tests/hooks-system.test.ts +++ b/integration-tests/hooks-system.test.ts @@ -7,9 +7,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, poll, normalizePath } from './test-helper.js'; import { join } from 'node:path'; -import { writeFileSync } from 'node:fs'; +import { writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import os from 'node:os'; -describe('Hooks System Integration', () => { +describe('Hooks System Integration', { timeout: 120000 }, () => { let rig: TestRig; beforeEach(() => { @@ -2016,6 +2017,10 @@ console.log(JSON.stringify({ // 3. Final setup with full settings rig.setup('Hook Disabling Multiple Ops', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.disabled-via-command.responses', + ), settings: { hooksConfig: { enabled: true, @@ -2230,7 +2235,7 @@ console.log(JSON.stringify({ // The hook should have stopped execution message (returned from tool) expect(result).toContain( - 'Agent execution stopped: Emergency Stop triggered by hook', + 'Agent execution stopped by hook: Emergency Stop triggered by hook', ); // Tool should NOT be called successfully (it was blocked/stopped) @@ -2242,4 +2247,210 @@ console.log(JSON.stringify({ expect(writeFileCalls).toHaveLength(0); }); }); + + describe('Hooks "ask" Decision Integration', () => { + it( + 'should force confirmation prompt when hook returns "ask" decision even in YOLO mode', + { timeout: 60000 }, + async () => { + const testName = + 'should force confirmation prompt when hook returns "ask" decision even in YOLO mode'; + + // 1. Setup hook script that returns 'ask' decision + const hookOutput = { + decision: 'ask', + systemMessage: 'Confirmation forced by security hook', + hookSpecificOutput: { + hookEventName: 'BeforeTool', + }, + }; + + const hookScript = `console.log(JSON.stringify(${JSON.stringify( + hookOutput, + )}));`; + + // Create script path predictably + const scriptPath = join(os.tmpdir(), 'gemini-cli-tests-ask-hook.js'); + writeFileSync(scriptPath, hookScript); + + // 2. Setup rig with YOLO mode enabled but with the 'ask' hook + rig.setup(testName, { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.allow-tool.responses', + ), + settings: { + debugMode: true, + tools: { + approval: 'yolo', + }, + general: { + enableAutoUpdateNotification: false, + }, + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }); + + // Bypass terminal setup prompt and other startup banners + const stateDir = join(rig.homeDir!, '.gemini'); + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + writeFileSync( + join(stateDir, 'state.json'), + JSON.stringify({ + terminalSetupPromptShown: true, + hasSeenScreenReaderNudge: true, + tipsShown: 100, + }), + ); + + // 3. Run interactive and verify prompt appears despite YOLO mode + const run = await rig.runInteractive(); + + // Wait for prompt to appear + await run.expectText('Type your message', 30000); + + // Send prompt that will trigger write_file + await run.type('Create a file called ask-test.txt with content "test"'); + await run.type('\r'); + + // Wait for the FORCED confirmation prompt to appear + // It should contain the system message from the hook + await run.expectText('Confirmation forced by security hook', 30000); + await run.expectText('Allow', 5000); + + // 4. Approve the permission + await run.type('y'); + await run.type('\r'); + + // Wait for command to execute + await run.expectText('approved.txt', 30000); + + // Should find the tool call + const foundWriteFile = await rig.waitForToolCall('write_file'); + expect(foundWriteFile).toBeTruthy(); + + // File should be created + const fileContent = rig.readFile('approved.txt'); + expect(fileContent).toBe('Approved content'); + }, + ); + + it( + 'should allow cancelling when hook forces "ask" decision', + { timeout: 60000 }, + async () => { + const testName = + 'should allow cancelling when hook forces "ask" decision'; + const hookOutput = { + decision: 'ask', + systemMessage: 'Confirmation forced for cancellation test', + hookSpecificOutput: { + hookEventName: 'BeforeTool', + }, + }; + + const hookScript = `console.log(JSON.stringify(${JSON.stringify( + hookOutput, + )}));`; + + const scriptPath = join( + os.tmpdir(), + 'gemini-cli-tests-ask-cancel-hook.js', + ); + writeFileSync(scriptPath, hookScript); + + rig.setup(testName, { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.allow-tool.responses', + ), + settings: { + debugMode: true, + tools: { + approval: 'yolo', + }, + general: { + enableAutoUpdateNotification: false, + }, + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }); + + // Bypass terminal setup prompt and other startup banners + const stateDir = join(rig.homeDir!, '.gemini'); + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + writeFileSync( + join(stateDir, 'state.json'), + JSON.stringify({ + terminalSetupPromptShown: true, + hasSeenScreenReaderNudge: true, + tipsShown: 100, + }), + ); + + const run = await rig.runInteractive(); + + // Wait for prompt to appear + await run.expectText('Type your message', 30000); + + await run.type( + 'Create a file called cancel-test.txt with content "test"', + ); + await run.type('\r'); + + await run.expectText( + 'Confirmation forced for cancellation test', + 30000, + ); + + // 4. Deny the permission using option 4 + await run.type('4'); + await run.type('\r'); + + // Wait for cancellation message + await run.expectText('Cancelled', 15000); + + // Tool should NOT be called successfully + const toolLogs = rig.readToolLogs(); + const writeFileCalls = toolLogs.filter( + (t) => + t.toolRequest.name === 'write_file' && + t.toolRequest.success === true, + ); + expect(writeFileCalls).toHaveLength(0); + }, + ); + }); }); diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index 215cf21226..473b966d5a 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -81,7 +81,9 @@ describe('JSON output', () => { const message = (thrown as Error).message; // Use a regex to find the first complete JSON object in the string - const jsonMatch = message.match(/{[\s\S]*}/); + // We expect the JSON to start with a quote (e.g. {"error": ...}) to avoid + // matching random error objects printed to stderr (like ENOENT). + const jsonMatch = message.match(/{\s*"[\s\S]*}/); // Fail if no JSON-like text was found expect( diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index f71006a36c..8709aac189 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, checkModelOutputContent } from './test-helper.js'; +import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js'; describe('Plan Mode', () => { let rig: TestRig; @@ -62,50 +64,98 @@ describe('Plan Mode', () => { }); }); - it.skip('should allow write_file only in the plans directory in plan mode', async () => { - await rig.setup( - 'should allow write_file only in the plans directory in plan mode', - { - settings: { - experimental: { plan: true }, - tools: { - core: ['write_file', 'read_file', 'list_directory'], - allowed: ['write_file'], + it('should allow write_file to the plans directory in plan mode', async () => { + const plansDir = '.gemini/tmp/foo/123/plans'; + const testName = + 'should allow write_file to the plans directory in plan mode'; + + await rig.setup(testName, { + settings: { + experimental: { plan: true }, + tools: { + core: ['write_file', 'read_file', 'list_directory'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, }, - general: { defaultApprovalMode: 'plan' }, }, }, - ); - - // We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory. - // Verify that write_file outside of plan directory fails - await rig.run({ - approvalMode: 'plan', - stdin: - 'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory', }); - const toolLogs = rig.readToolLogs(); - const writeLogs = toolLogs.filter( - (l) => l.toolRequest.name === 'write_file', + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); - const planWrite = writeLogs.find( + const run = await rig.runInteractive({ + approvalMode: 'plan', + }); + + await run.type('Create a file called plan.md in the plans directory.'); + await run.type('\r'); + + await rig.expectToolCallSuccess(['write_file'], 30000, (args) => + args.includes('plan.md'), + ); + + const toolLogs = rig.readToolLogs(); + const planWrite = toolLogs.find( (l) => + l.toolRequest.name === 'write_file' && l.toolRequest.args.includes('plans') && l.toolRequest.args.includes('plan.md'), ); + expect(planWrite?.toolRequest.success).toBe(true); + }); - const blockedWrite = writeLogs.find((l) => - l.toolRequest.args.includes('hello.txt'), + it('should deny write_file to non-plans directory in plan mode', async () => { + const plansDir = '.gemini/tmp/foo/123/plans'; + const testName = + 'should deny write_file to non-plans directory in plan mode'; + + await rig.setup(testName, { + settings: { + experimental: { plan: true }, + tools: { + core: ['write_file', 'read_file', 'list_directory'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, + }, + }, + }, + }); + + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); - // Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't - if (blockedWrite) { - expect(blockedWrite?.toolRequest.success).toBe(false); - } + const run = await rig.runInteractive({ + approvalMode: 'plan', + }); - expect(planWrite?.toolRequest.success).toBe(true); + await run.type('Create a file called hello.txt in the current directory.'); + await run.type('\r'); + + const toolLogs = rig.readToolLogs(); + const writeLog = toolLogs.find( + (l) => + l.toolRequest.name === 'write_file' && + l.toolRequest.args.includes('hello.txt'), + ); + + // In Plan Mode, writes outside the plans directory should be blocked. + // Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail. + if (writeLog) { + expect(writeLog.toolRequest.success).toBe(false); + } }); it('should be able to enter plan mode from default mode', async () => { @@ -119,6 +169,12 @@ describe('Plan Mode', () => { }, }); + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), + ); + // Start in default mode and ask to enter plan mode. await rig.run({ approvalMode: 'default', @@ -126,10 +182,7 @@ describe('Plan Mode', () => { 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.', }); - const enterPlanCallFound = await rig.waitForToolCall( - 'enter_plan_mode', - 10000, - ); + const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode'); expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe( true, ); diff --git a/integration-tests/policy-headless-readonly.responses b/integration-tests/policy-headless-readonly.responses new file mode 100644 index 0000000000..35ba546bae --- /dev/null +++ b/integration-tests/policy-headless-readonly.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will read the content of the file to identify its"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":11,"totalTokenCount":8061,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":" language.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":14,"totalTokenCount":8064,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"test.txt"}},"thoughtSignature":"EvkCCvYCAb4+9vt8mJ/o45uuuAJtfjaZ3YzkJzqXHZBttRE+Om0ahcr1S5RDFp50KpgHtJtbAH1pwEXampOnDV3WKiWwA+e3Jnyk4CNQegz7ZMKsl55Nem2XDViP8BZKnJVqGmSFuMoKJLFmbVIxKejtWcblfn3httbGsrUUNbHwdPjPHo1qY043lF63g0kWx4v68gPSsJpNhxLrSugKKjiyRFN+J0rOIBHI2S9MdZoHEKhJxvGMtXiJquxmhPmKcNEsn+hMdXAZB39hmrRrGRHDQPVYVPhfJthVc73ufzbn+5KGJpaMQyKY5hqrc2ea8MHz+z6BSx+tFz4NZBff1tJQOiUp09/QndxQRZHSQZr1ALGy0O1Qw4JqsX94x81IxtXqYkSRo3zgm2vl/xPMC5lKlnK5xoKJmoWaHkUNeXs/sopu3/Waf1a5Csoh9ImnKQsW0rJ6GRyDQvky1FwR6Aa98bgfNdcXOPHml/BtghaqRMXTiG6vaPJ8UFs="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":81}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The language of the file is Latin."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8054,"candidatesTokenCount":8,"totalTokenCount":8078,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8054}],"thoughtsTokenCount":16}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"EnIKcAG+Pvb7vnRBJVz3khx1oArQQqTNvXOXkliNQS7NvYw94dq5m+wGKRmSj3egO3GVp7pacnAtLn9NT1ABKBGpa7MpRhiAe3bbPZfkqOuveeyC19LKQ9fzasCywiYqg5k5qSxfjs5okk+O0NLOvTjN/tg="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8135,"candidatesTokenCount":8,"totalTokenCount":8159,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8135}],"thoughtsTokenCount":16}}]} diff --git a/integration-tests/policy-headless-shell-allowed.responses b/integration-tests/policy-headless-shell-allowed.responses new file mode 100644 index 0000000000..7c98e60db0 --- /dev/null +++ b/integration-tests/policy-headless-shell-allowed.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will run the requested"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":5,"totalTokenCount":8092,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":" shell command to verify the policy configuration.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":14,"totalTokenCount":8101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"echo POLICY_TEST_ECHO_COMMAND","description":"Echo the test string to verify policy settings."}},"thoughtSignature":"EpwFCpkFAb4+9vulXgVj96CAm2eMFbDEGHz9B37GwI8N1KOvu9AHwdYWiita7yS4RKAdeBui22B5320XBaxOtZGnMo2E9pG0Pcus2WsBiecRaHUTxTmhx1BvURevrs+5m4UJeLRGMfP94+ncha4DeIQod3PKBnK8xeIJTyZBFB7+hmHbHvem2VwZh/v14e4fXlpEkkdntJbzrA1nUdctIGdEmdm0sL8PaFnMqWLUnkZvGdfq7ctFt9EYk2HW2SrHVhk3HdsyWhoxNz2MU0sRWzAgiSQY/heSSAbU7Jdgg0RjwB9o3SkCIHxqnVpkH8PQsARwnah5I5s7pW6EHr3D4f1/UVl0n26hyI2xBqF/n4aZKhtX55U4h/DIhxooZa2znstt6BS8vRcdzflFrX7OV86WQxHE4JHjQecP2ciBRimm8pL3Od3pXnRcx32L8JbrWm6dPyWlo5h5uCRy0qXye2+3SuHs5wtxOjD9NETR4TwzqFe+m0zThpxsR1ZKQeKlO7lN/s3pWih/TjbZQEQs9xr72UnlE8ZtJ4bOKj8GNbemvsrbYAO98NzJwvdil0FhblaXmReP1uYjucmLC0jCJHShqNz2KzAkDTvKs4tmio13IuCRjTZ3E5owqCUn7djDqOSDwrg235RIVJkiDIaPlHemOR15lbVQD1VOzytzT8TZLEzTV750oyHq/IhLMQHYixO8jJ2GkVvUp7bxz9oQ4UeTqT5lTF4s40H2Rlkb6trF4hKXoFhzILy1aOJTC9W3fCoop7VJLIMNulgHLWxiq65Uas6sIep87yiD4xLfbGfMm6HS4JTRhPlfxeckn/SzUfu1afg1nAvW3vBlR/YNREf0N28/PnRC08VYqA3mqCRiyPqPWsf3a0jyio0dD9A="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":138}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"POLICY_TEST_"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":4,"totalTokenCount":8046,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":"ECHO_COMMAND"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":8,"totalTokenCount":8050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8180,"candidatesTokenCount":8,"totalTokenCount":8188,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8180}]}}]} diff --git a/integration-tests/policy-headless-shell-denied.responses b/integration-tests/policy-headless-shell-denied.responses new file mode 100644 index 0000000000..4278543b7e --- /dev/null +++ b/integration-tests/policy-headless-shell-denied.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Assessing Command Execution**\n\nOkay, I'm currently assessing the feasibility of executing `echo POLICY_TEST_ECHO_COMMAND` using the `run_shell_command` function. Restrictions are being evaluated; the prompt is specifically geared towards a successful command output: \"POLICY_TEST_ECHO_COMMAND\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"totalTokenCount":7949,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}]}},{"candidates":[{"content":{"parts":[{"text":"I will execute the requested echo"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":6,"totalTokenCount":8161,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":" command to verify the policy."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":12,"totalTokenCount":8167,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"description":"Execute the echo command as requested.","command":"echo POLICY_TEST_ECHO_COMMAND"}},"thoughtSignature":"EvkGCvYGAb4+9vucYbmJ8DrNCca9c0C8o4qKQ6V2WnzmT4mbCw8V7s0+2I/PoxrgnsxZJIIRM8y5E4bW7Jbs46GjbJ2cefY9Q3iC45eiGS5Gqvq0eAG04N3GZRwizyDOp+wJlBsaPu1cNB1t6CnMk/ZHDAHEIQUpYfYWmPudbHOQMspGMu3bX23YSI1+Q5vPVdOtM16J3EFbk3dCp+RnPa/8tVC+5AqFlLveuDbJXtrLN9wAyf4SjnPhn9BPfD0bgas3+gF03qRJvWoNcnnJiYxL3DNQtjsAYJ7IWRzciYYZSTm99blD730bn3NzvSObhlHDtb3hFpApYvG396+3prsgJg0Yjef54B4KxHfZaQbE2ndSP5zGrwLtVD5y7XJAYskvhiUqwPFHNVykqroEMzPn8wWQSGvonNR6ezcMIsUV5xwnxZDaPhvrDdIwF4NR1F5DeriJRu27+fwtCApeYkx9mPx4LqnyxOuVsILjzdSPHE6Bqf690VJSXpo67lCN4F3DRRYIuCD4UOlf8V3dvUO6BKjvChDDWnIq7KPoByDQT9VhVlZvS3/nYlkeDuhi0rk2jpByN1NdgD2YSvOlpJcka8JqKQ+lnO/7Swunij2ISUfpL2hkx6TEHjebPU2dBQkub5nSl9J1EhZn4sUGG5r6Zdv1lYcpIcO4ZYeMqZZ4uNvTvSpGdT4Jj1+qS88taKgYq7uN1RgQSTsT5wcpmlubIpgIycNwAIRFvN+DjkQjiUC6hSqdeOx3dc7LWgC/O/+PRog7kuFrD2nzih+oIP0YxXrLA9CMVPlzeAgPUi9b75HAJQ92GRHxfQ163tjZY+4bWmJtcU4NBqGH0x/jLEU9xCojTeh+mZoUDGsb3N+bVcGJftRIet7IBYveD29Z+XHtKhf7s/YIkFW8lgsG8Q0EtNchCxqIQxf9UjYEO52RhCx7i7zScB1knovt2HAotACKqDdPqg18PmpDv8Frw6Y66XeCCJzBCmNcSUTETq3K05gwkU8nyANQtjbJT0wF4LS9h5vPE+Vc7/dGH6pi1TgxWB/n4q1IXfNqilo/h2Pyw01VPsHKthNtKKq1/nSW/WuEU0rimqu7wHplMqU2nwRDCTNE9pPO59RtTHMfUxxd8yEgKBj9L8MiQGM5isIYl/lJtvucee4HD9iLpbYADlrQAlUCd0rg/z+5sQ=="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":206}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"AR NAR"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8020,"candidatesTokenCount":2,"totalTokenCount":8049,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8020}],"thoughtsTokenCount":27}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"Er8BCrwBAb4+9vv6KGeMf6yopmPBE/az7Kjdp+Pe5a/R6wgXcyCZzGNwkwKFW3i3ro0j26bRrVeHD1zRfWFTIGdOSZKV6OMPWLqFC/RU6CNJ88B1xY7hbCVwA7EchYPzgd3YZRVNwmFu52j86/9qXf/zaqTFN+WQ0mUESJXh2O2YX8E7imAvxhmRdobVkxvEt4ZX3dW5skDhXHMDZOxbLpX0nkK7cWWS7iEc+qBFP0yinlA/eiG2ZdKpuTiDl76a9ik="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8226,"candidatesTokenCount":2,"totalTokenCount":8255,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8226}],"thoughtsTokenCount":27}}]} diff --git a/integration-tests/policy-headless.test.ts b/integration-tests/policy-headless.test.ts new file mode 100644 index 0000000000..b6cc14f61c --- /dev/null +++ b/integration-tests/policy-headless.test.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join } from 'node:path'; +import { TestRig } from './test-helper.js'; + +interface PromptCommand { + prompt: (testFile: string) => string; + tool: string; + command: string; + expectedSuccessResult: string; + expectedFailureResult: string; +} + +const ECHO_PROMPT: PromptCommand = { + command: 'echo', + prompt: () => + `Use the \`echo POLICY_TEST_ECHO_COMMAND\` shell command. On success, ` + + `your final response must ONLY be "POLICY_TEST_ECHO_COMMAND". If the ` + + `command fails output AR NAR and stop.`, + tool: 'run_shell_command', + expectedSuccessResult: 'POLICY_TEST_ECHO_COMMAND', + expectedFailureResult: 'AR NAR', +}; + +const READ_FILE_PROMPT: PromptCommand = { + prompt: (testFile: string) => + `Read the file ${testFile} and tell me what language it is, if the ` + + `read_file tool fails output AR NAR and stop.`, + tool: 'read_file', + command: '', + expectedSuccessResult: 'Latin', + expectedFailureResult: 'AR NAR', +}; + +async function waitForToolCallLog( + rig: TestRig, + tool: string, + command: string, + timeout: number = 15000, +) { + const foundToolCall = await rig.waitForToolCall(tool, timeout, (args) => + args.toLowerCase().includes(command.toLowerCase()), + ); + + expect(foundToolCall).toBe(true); + + const toolLogs = rig + .readToolLogs() + .filter((toolLog) => toolLog.toolRequest.name === tool); + const log = toolLogs.find( + (toolLog) => + !command || + toolLog.toolRequest.args.toLowerCase().includes(command.toLowerCase()), + ); + + // The policy engine should have logged the tool call + expect(log).toBeTruthy(); + return log; +} + +async function verifyToolExecution( + rig: TestRig, + promptCommand: PromptCommand, + result: string, + expectAllowed: boolean, + expectedDenialString?: string, +) { + const log = await waitForToolCallLog( + rig, + promptCommand.tool, + promptCommand.command, + ); + + if (expectAllowed) { + expect(log!.toolRequest.success).toBe(true); + expect(result).not.toContain('Tool execution denied by policy'); + expect(result).not.toContain(`Tool "${promptCommand.tool}" not found`); + expect(result).toContain(promptCommand.expectedSuccessResult); + } else { + expect(log!.toolRequest.success).toBe(false); + expect(result).toContain( + expectedDenialString || 'Tool execution denied by policy', + ); + expect(result).toContain(promptCommand.expectedFailureResult); + } +} + +interface TestCase { + name: string; + responsesFile: string; + promptCommand: PromptCommand; + policyContent?: string; + expectAllowed: boolean; + expectedDenialString?: string; +} + +describe('Policy Engine Headless Mode', () => { + let rig: TestRig; + let testFile: string; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + const runTestCase = async (tc: TestCase) => { + const fakeResponsesPath = join(import.meta.dirname, tc.responsesFile); + rig.setup(tc.name, { fakeResponsesPath }); + + testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); + const args = ['-p', tc.promptCommand.prompt(testFile)]; + + if (tc.policyContent) { + const policyPath = rig.createFile('test-policy.toml', tc.policyContent); + args.push('--policy', policyPath); + } + + const result = await rig.run({ + args, + approvalMode: 'default', + }); + + await verifyToolExecution( + rig, + tc.promptCommand, + result, + tc.expectAllowed, + tc.expectedDenialString, + ); + }; + + const testCases = [ + { + name: 'should deny ASK_USER tools by default in headless mode', + responsesFile: 'policy-headless-shell-denied.responses', + promptCommand: ECHO_PROMPT, + expectAllowed: false, + expectedDenialString: 'Tool "run_shell_command" not found', + }, + { + name: 'should allow ASK_USER tools in headless mode if explicitly allowed via policy file', + responsesFile: 'policy-headless-shell-allowed.responses', + promptCommand: ECHO_PROMPT, + policyContent: ` + [[rule]] + toolName = "run_shell_command" + decision = "allow" + priority = 100 + `, + expectAllowed: true, + }, + { + name: 'should allow read-only tools by default in headless mode', + responsesFile: 'policy-headless-readonly.responses', + promptCommand: READ_FILE_PROMPT, + expectAllowed: true, + }, + { + name: 'should allow specific shell commands in policy file', + responsesFile: 'policy-headless-shell-allowed.responses', + promptCommand: ECHO_PROMPT, + policyContent: ` + [[rule]] + toolName = "run_shell_command" + commandPrefix = "${ECHO_PROMPT.command}" + decision = "allow" + priority = 100 + `, + expectAllowed: true, + }, + { + name: 'should deny other shell commands in policy file', + responsesFile: 'policy-headless-shell-denied.responses', + promptCommand: ECHO_PROMPT, + policyContent: ` + [[rule]] + toolName = "run_shell_command" + commandPrefix = "node" + decision = "allow" + priority = 100 + `, + expectAllowed: false, + expectedDenialString: 'Tool execution denied by policy', + }, + ]; + + it.each(testCases)( + '$name', + async (tc) => { + await runTestCase(tc); + }, + // Large timeout for regeneration + process.env['REGENERATE_MODEL_GOLDENS'] === 'true' ? 120000 : undefined, + ); +}); diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts index 0587bb30df..8ae72fed84 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/run_shell_command.test.ts @@ -18,6 +18,7 @@ const { shell } = getShellConfiguration(); function getLineCountCommand(): { command: string; tool: string } { switch (shell) { case 'powershell': + return { command: `Measure-Object -Line`, tool: 'Measure-Object' }; case 'cmd': return { command: `find /c /v`, tool: 'find' }; case 'bash': @@ -238,8 +239,12 @@ describe('run_shell_command', () => { }); it('should succeed in yolo mode', async () => { + const isWindows = process.platform === 'win32'; await rig.setup('should succeed in yolo mode', { - settings: { tools: { core: ['run_shell_command'] } }, + settings: { + tools: { core: ['run_shell_command'] }, + shell: isWindows ? { enableInteractiveShell: false } : undefined, + }, }); const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); diff --git a/integration-tests/user-policy.responses b/integration-tests/user-policy.responses new file mode 100644 index 0000000000..be840600ca --- /dev/null +++ b/integration-tests/user-policy.responses @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"ls -F"}}}]},"finishReason":"STOP","index":0}]},{"candidates":[{"content":{"parts":[{"text":"I ran ls -F"}]},"finishReason":"STOP","index":0}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I ran ls -F"}]},"finishReason":"STOP","index":0}]}]} diff --git a/integration-tests/user-policy.test.ts b/integration-tests/user-policy.test.ts new file mode 100644 index 0000000000..a07d6bcdea --- /dev/null +++ b/integration-tests/user-policy.test.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join } from 'node:path'; +import { TestRig, GEMINI_DIR } from './test-helper.js'; +import fs from 'node:fs'; + +describe('User Policy Regression Repro', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + it('should respect policies in ~/.gemini/policies/allowed-tools.toml', async () => { + rig.setup('user-policy-test', { + fakeResponsesPath: join(import.meta.dirname, 'user-policy.responses'), + }); + + // Create ~/.gemini/policies/allowed-tools.toml + const userPoliciesDir = join(rig.homeDir!, GEMINI_DIR, 'policies'); + fs.mkdirSync(userPoliciesDir, { recursive: true }); + fs.writeFileSync( + join(userPoliciesDir, 'allowed-tools.toml'), + ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "ls -F" +decision = "allow" +priority = 100 + `, + ); + + // Run gemini with a prompt that triggers ls -F + // approvalMode: 'default' in headless mode will DENY if it hits ASK_USER + const result = await rig.run({ + args: ['-p', 'Run ls -F', '--model', 'gemini-3.1-pro-preview'], + approvalMode: 'default', + }); + + expect(result).toContain('I ran ls -F'); + expect(result).not.toContain('Tool execution denied by policy'); + expect(result).not.toContain('Tool "run_shell_command" not found'); + + const toolLogs = rig.readToolLogs(); + const lsLog = toolLogs.find( + (l) => + l.toolRequest.name === 'run_shell_command' && + l.toolRequest.args.includes('ls -F'), + ); + expect(lsLog).toBeDefined(); + expect(lsLog?.toolRequest.success).toBe(true); + }); + + it('should FAIL if policy is not present (sanity check)', async () => { + rig.setup('user-policy-sanity-check', { + fakeResponsesPath: join(import.meta.dirname, 'user-policy.responses'), + }); + + // DO NOT create the policy file here + + // Run gemini with a prompt that triggers ls -F + const result = await rig.run({ + args: ['-p', 'Run ls -F', '--model', 'gemini-3.1-pro-preview'], + approvalMode: 'default', + }); + + // In non-interactive mode, it should be denied + expect(result).toContain('Tool "run_shell_command" not found'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 5f0c5f058d..b70dc1413b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "workspaces": [ "packages/*" ], @@ -22,7 +22,7 @@ "gemini": "bundle/gemini.js" }, "devDependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", @@ -83,43 +83,10 @@ "node-pty": "^1.0.0" } }, - "node_modules/@a2a-js/sdk": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.8.tgz", - "integrity": "sha512-vAg6JQbhOnHTzApsB7nGzCQ9r7PuY4GMr8gt88dIR8Wc8G8RSqVTyTmFeMurgzcYrtHYXS3ru2rnDoGj9UDeSw==", - "license": "Apache-2.0", - "dependencies": { - "uuid": "^11.1.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "express": "^4.21.2 || ^5.1.0" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - } - }, - "node_modules/@a2a-js/sdk/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz", - "integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==", + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz", + "integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -515,6 +482,12 @@ "node": ">=18" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", @@ -999,9 +972,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", "dependencies": { @@ -1031,9 +1004,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "license": "MIT", "engines": { @@ -1041,13 +1014,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", + "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1055,62 +1028,20 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", + "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1121,20 +1052,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", - "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.14.0", + "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.3", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1144,29 +1075,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -1180,26 +1088,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", "dev": true, "license": "MIT", "engines": { @@ -1210,9 +1102,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1220,19 +1112,32 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@google-cloud/common": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz", @@ -1472,21 +1377,19 @@ "link": true }, "node_modules/@google/genai": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz", - "integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz", + "integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", - "p-retry": "^7.1.1", - "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" + "@modelcontextprotocol/sdk": "^1.20.1" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { @@ -1613,9 +1516,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -2292,7 +2195,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2473,7 +2375,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2523,7 +2424,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2898,7 +2798,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2932,7 +2831,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2987,7 +2885,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -3057,6 +2954,12 @@ "node": ">=12.22.0" } }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "license": "ISC" + }, "node_modules/@pnpm/npm-conf": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz", @@ -3135,10 +3038,31 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -3149,9 +3073,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -3162,9 +3086,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -3175,9 +3099,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -3188,9 +3112,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -3201,9 +3125,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -3214,9 +3138,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -3227,9 +3151,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -3240,9 +3164,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -3253,9 +3177,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -3266,9 +3190,22 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -3279,9 +3216,22 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -3292,9 +3242,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -3305,9 +3255,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -3318,9 +3268,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -3331,9 +3281,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -3344,9 +3294,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -3356,10 +3306,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -3370,9 +3333,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -3383,9 +3346,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -3396,9 +3359,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -3409,9 +3372,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -3529,9 +3492,9 @@ } }, "node_modules/@secretlint/formatter/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", "dev": true, "license": "MIT", "engines": { @@ -3820,6 +3783,12 @@ "node": ">= 10" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@ts-morph/common": { "version": "0.12.3", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.12.3.tgz", @@ -3833,45 +3802,6 @@ "path-browserify": "^1.0.1" } }, - "node_modules/@ts-morph/common/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -4046,6 +3976,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-stable-stringify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz", + "integrity": "sha512-ESTsHWB72QQq+pjUFIbEz9uSCZppD31YrVkbt2rnUciTYEvcwN6uZIhX5JZeBHqRlFJ41x/7MewCs7E2Qux6Cg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -4184,7 +4121,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4414,20 +4350,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", + "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", - "ignore": "^7.0.5", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/type-utils": "8.35.0", + "@typescript-eslint/utils": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4437,9 +4374,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.35.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -4453,18 +4390,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", + "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", - "debug": "^4.4.3" + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", + "debug": "^4.3.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4474,20 +4410,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", + "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", - "debug": "^4.4.3" + "@typescript-eslint/tsconfig-utils": "^8.35.0", + "@typescript-eslint/types": "^8.35.0", + "debug": "^4.3.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4497,18 +4433,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", - "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", + "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1" + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4519,9 +4455,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", + "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", "dev": true, "license": "MIT", "engines": { @@ -4532,21 +4468,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", + "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "@typescript-eslint/typescript-estree": "8.35.0", + "@typescript-eslint/utils": "8.35.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4556,14 +4491,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", + "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", "dev": true, "license": "MIT", "engines": { @@ -4575,21 +4510,22 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", + "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.1", - "@typescript-eslint/tsconfig-utils": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "@typescript-eslint/project-service": "8.35.0", + "@typescript-eslint/tsconfig-utils": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/visitor-keys": "8.35.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4599,59 +4535,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", + "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.35.0", + "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/typescript-estree": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4661,19 +4558,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", + "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "eslint-visitor-keys": "^5.0.0" + "@typescript-eslint/types": "8.35.0", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4683,19 +4580,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.0.tgz", @@ -4772,6 +4656,148 @@ } } }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", + "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.47.0", + "@typescript-eslint/types": "^8.47.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", + "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", + "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", + "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", + "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.47.0", + "@typescript-eslint/tsconfig-utils": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", + "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", + "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.47.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -4881,17 +4907,17 @@ } }, "node_modules/@vscode/vsce": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz", - "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz", + "integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==", "dev": true, "license": "MIT", "dependencies": { "@azure/identity": "^4.1.0", - "@secretlint/node": "^10.1.2", - "@secretlint/secretlint-formatter-sarif": "^10.1.2", - "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", - "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@secretlint/node": "^10.1.1", + "@secretlint/secretlint-formatter-sarif": "^10.1.1", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.1", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.1", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", @@ -4908,7 +4934,7 @@ "minimatch": "^3.0.3", "parse-semver": "^1.1.1", "read": "^1.0.7", - "secretlint": "^10.1.2", + "secretlint": "^10.1.1", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", @@ -5072,70 +5098,6 @@ "win32" ] }, - "node_modules/@vscode/vsce/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@vscode/vsce/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@vscode/vsce/node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@vscode/vsce/node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -5197,9 +5159,9 @@ } }, "node_modules/@vue/compiler-core/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -5306,7 +5268,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5334,9 +5295,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", "engines": { "node": ">= 14" @@ -5399,9 +5360,9 @@ "license": "MIT" }, "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "license": "MIT", "dependencies": { "environment": "^1.0.0" @@ -5657,6 +5618,18 @@ "node": ">=12" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", @@ -5749,12 +5722,115 @@ "typed-rest-client": "^1.8.4" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz", + "integrity": "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", + "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.21.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -5776,6 +5852,15 @@ ], "license": "MIT" }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -5863,14 +5948,15 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -5965,7 +6051,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -6132,9 +6217,9 @@ } }, "node_modules/cheerio/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -6145,9 +6230,9 @@ } }, "node_modules/cheerio/node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -6160,8 +6245,8 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" + "domutils": "^3.2.1", + "entities": "^6.0.0" } }, "node_modules/chownr": { @@ -6173,6 +6258,32 @@ "node": ">=18" } }, + "node_modules/chrome-devtools-mcp": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-0.19.0.tgz", + "integrity": "sha512-LfqjOxdUjWvCQrfeI5V3ZBJCUIDKGNmexSbSAgsrjVggN4X1OSObLxleSlX2zwcXRZYxqy209cww0MXcXuN1zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "chrome-devtools-mcp": "build/src/index.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", @@ -6235,9 +6346,9 @@ } }, "node_modules/cli-truncate/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", "engines": { "node": ">=12" @@ -6247,9 +6358,9 @@ } }, "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { @@ -6307,16 +6418,16 @@ "node": ">= 12" } }, - "node_modules/clipboardy": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.0.0.tgz", - "integrity": "sha512-MQfKHaD09eP80Pev4qBxZLbxJK/ONnqfSYAPlCmPh+7BDboYtO/3BmB6HGzxDIT0SlTRc2tzS8lQqfcdLtZ0Kg==", + "node_modules/clipboard-image": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/clipboard-image/-/clipboard-image-0.1.0.tgz", + "integrity": "sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg==", "license": "MIT", "dependencies": { - "execa": "^9.6.0", - "is-wayland": "^0.1.0", - "is-wsl": "^3.1.0", - "is64bit": "^2.0.0" + "run-jxa": "^3.0.0" + }, + "bin": { + "clipboard-image": "cli.js" }, "engines": { "node": ">=20" @@ -6473,12 +6584,6 @@ "color-name": "1.1.3" } }, - "node_modules/color/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -6550,13 +6655,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -6738,6 +6836,33 @@ "node": ">= 8" } }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -6957,7 +7082,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -7001,6 +7125,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7068,29 +7206,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/depcheck/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/depcheck/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/depcheck/node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -7150,22 +7265,6 @@ "node": ">=6" } }, - "node_modules/depcheck/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/depcheck/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -7299,6 +7398,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1581282", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", + "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", + "license": "BSD-3-Clause" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -7402,9 +7507,9 @@ } }, "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.1.0.tgz", + "integrity": "sha512-tG9VUTJTuju6GcXgbdsOuRhupE8cb4mRgY5JLRCh4MtGoVo3/gfGUtOMwmProM6d0ba2mCFvv+WrpYJV6qgJXQ==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -7854,26 +7959,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", + "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", + "@eslint/config-array": "^0.20.1", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", - "@eslint/plugin-kit": "^0.4.1", + "@eslint/js": "9.29.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -8028,29 +8154,6 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, - "node_modules/eslint-plugin-import/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -8061,22 +8164,6 @@ "ms": "^2.1.1" } }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/eslint-plugin-import/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8133,65 +8220,20 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, - "engines": { - "node": ">= 0.4" - }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8236,45 +8278,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -8336,7 +8339,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -8355,7 +8357,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -8407,6 +8408,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8493,7 +8503,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8533,12 +8542,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -8614,6 +8623,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -8680,10 +8695,10 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-parser": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", - "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "node_modules/fast-xml-builder": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz", + "integrity": "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==", "funding": [ { "type": "github", @@ -8692,6 +8707,23 @@ ], "license": "MIT", "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.3.tgz", + "integrity": "sha512-Ymnuefk6VzAhT3SxLzVUw+nMio/wB1NGypHkgetwtXcK1JfryaHk4DWQFGVwQ9XgzyS5iRZ7C2ZGI4AMsdMZ6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.2", + "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { @@ -8913,9 +8945,9 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, "license": "MIT", "dependencies": { @@ -8938,16 +8970,6 @@ "node": ">= 18" } }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/form-data/node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", @@ -9142,9 +9164,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", "license": "MIT", "engines": { "node": ">=18" @@ -9249,6 +9271,29 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/glob": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", @@ -9302,42 +9347,6 @@ "tslib": "2" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/global-modules": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", @@ -9384,9 +9393,9 @@ } }, "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", "dev": true, "license": "MIT", "engines": { @@ -9613,10 +9622,22 @@ "url": "https://github.com/sindresorhus/got?sponsor=1" } }, + "node_modules/got/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/gradient-string": { @@ -9632,6 +9653,13 @@ "node": ">=10" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/graphql": { "version": "16.11.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", @@ -9690,7 +9718,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -9784,11 +9811,10 @@ } }, "node_modules/hono": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", - "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", + "version": "4.12.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", + "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -9893,7 +9919,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -10068,7 +10093,6 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -10149,9 +10173,9 @@ } }, "node_modules/ink/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", "engines": { "node": ">=12" @@ -10161,9 +10185,9 @@ } }, "node_modules/ink/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -10194,13 +10218,13 @@ "license": "ISC" }, "node_modules/ink/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" }, "engines": { "node": ">=20" @@ -10209,6 +10233,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ink/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -10225,9 +10261,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -10510,18 +10546,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-network-error": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", - "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-node-process": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", @@ -10808,7 +10832,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -11032,6 +11055,25 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -11080,6 +11122,15 @@ "node": ">= 10.0.0" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -11339,9 +11390,9 @@ } }, "node_modules/lint-staged/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", "dev": true, "license": "MIT", "engines": { @@ -11380,9 +11431,9 @@ } }, "node_modules/listr2/node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.0.tgz", + "integrity": "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==", "dev": true, "license": "MIT", "dependencies": { @@ -11397,14 +11448,14 @@ } }, "node_modules/listr2/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" }, "engines": { "node": ">=20" @@ -11684,6 +11735,21 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/macos-version": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/macos-version/-/macos-version-6.0.0.tgz", + "integrity": "sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -11819,6 +11885,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -11925,16 +11997,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", - "dev": true, - "license": "ISC", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -11947,10 +12021,10 @@ } }, "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } @@ -11967,6 +12041,12 @@ "node": ">= 18" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -12056,6 +12136,26 @@ } } }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "devOptional": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/multimatch": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", @@ -12083,45 +12183,6 @@ "dev": true, "license": "MIT" }, - "node_modules/multimatch/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/multimatch/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/multimatch/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -12186,6 +12247,15 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-addon-api": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", @@ -12213,35 +12283,6 @@ "node": ">=10.5.0" } }, - "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-exports-info/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -12401,29 +12442,6 @@ "node": ">=4" } }, - "node_modules/npm-run-all/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/npm-run-all/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/npm-run-all/node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -12473,22 +12491,6 @@ "dev": true, "license": "ISC" }, - "node_modules/npm-run-all/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/npm-run-all/node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -12555,9 +12557,9 @@ } }, "node_modules/npm-run-all2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, "license": "MIT", "engines": { @@ -12696,7 +12698,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12957,19 +12958,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", - "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", "license": "MIT", "dependencies": { - "is-network-error": "^1.1.0" + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" }, "engines": { - "node": ">=20" + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 14" } }, "node_modules/package-json": { @@ -13026,6 +13044,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -13169,6 +13199,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -13202,21 +13247,14 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "license": "BlueOak-1.0.0", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", "engines": { "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "devOptional": true, - "license": "MIT" - }, "node_modules/path-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", @@ -13369,6 +13407,18 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/powershell-utils": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", + "integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13410,6 +13460,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -13479,9 +13538,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -13515,6 +13574,40 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -13568,6 +13661,45 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "24.39.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.0.tgz", + "integrity": "sha512-SzIxz76Kgu17HUIi57HOejPiN0JKa9VCd2GcPY1sAh6RA4BzGZarFQdOYIYrBdUVbtyH7CrDb9uhGEwVXK/YNA==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1581282", + "typed-query-selector": "^2.12.1", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -13648,9 +13780,9 @@ } }, "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -13718,7 +13850,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13729,7 +13860,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13842,6 +13972,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read-package-up/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read-pkg": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", @@ -13861,6 +14003,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read/node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -14021,13 +14175,13 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -14175,9 +14329,9 @@ } }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -14190,28 +14344,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -14232,13 +14389,12 @@ } }, "node_modules/router/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">=16" } }, "node_modules/run-applescript": { @@ -14253,6 +14409,95 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/run-jxa": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/run-jxa/-/run-jxa-3.0.0.tgz", + "integrity": "sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA==", + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "macos-version": "^6.0.0", + "subsume": "^4.0.0", + "type-fest": "^2.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-jxa/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/run-jxa/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-jxa/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/run-jxa/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/run-jxa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/run-jxa/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -14482,7 +14727,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -14657,9 +14901,9 @@ } }, "node_modules/simple-git": { - "version": "3.28.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz", - "integrity": "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==", + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz", + "integrity": "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==", "license": "MIT", "dependencies": { "@kwsites/file-exists": "^1.1.1", @@ -14681,9 +14925,9 @@ } }, "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", "license": "MIT" }, "node_modules/sisteransi": { @@ -14706,9 +14950,9 @@ } }, "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -14722,9 +14966,9 @@ } }, "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", "engines": { "node": ">=12" @@ -14734,12 +14978,12 @@ } }, "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.1" + "get-east-asian-width": "^1.0.0" }, "engines": { "node": ">=18" @@ -14748,6 +14992,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -14876,6 +15168,17 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -15162,9 +15465,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "funding": [ { "type": "github", @@ -15189,6 +15492,34 @@ "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", "license": "MIT" }, + "node_modules/subsume": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/subsume/-/subsume-4.0.0.tgz", + "integrity": "sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^5.0.0", + "unique-string": "^3.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/subsume/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/superagent": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", @@ -15316,9 +15647,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.1", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.1.tgz", - "integrity": "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA==", + "version": "5.31.4", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.4.tgz", + "integrity": "sha512-lZppDyQx91VdS5zJvAyGkmwe+Mq6xY978BDUG2wRkWE+jkmUF5ti8cvOovFQoN5bvSFKCXVkyKEaU5ec3SJiRg==", "license": "MIT", "os": [ "darwin", @@ -15445,20 +15776,30 @@ "node": ">=8" } }, - "node_modules/tar": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.8.tgz", - "integrity": "sha512-SYkBtK99u0yXa+IWL0JRzzcl7RxNpvX/U08Z+8DKnysfno7M+uExnTZH8K+VGgShf2qFPKtbNr9QBl8n7WBP6Q==", - "license": "BlueOak-1.0.0", + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" + "pump": "^3.0.0", + "tar-stream": "^3.1.5" }, - "engines": { - "node": ">=18" + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, "node_modules/teeny-request": { @@ -15516,6 +15857,15 @@ "node": ">= 6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -15534,57 +15884,27 @@ } }, "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", - "minimatch": "^10.2.2" + "minimatch": "^9.0.4" }, "engines": { "node": ">=18" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "dev": true, - "license": "MIT", + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "b4a": "^1.6.4" } }, "node_modules/text-hex": { @@ -15689,7 +16009,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15831,9 +16150,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "license": "MIT", "engines": { @@ -15912,9 +16231,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -15922,7 +16239,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15961,12 +16277,12 @@ } }, "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=16" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -16064,6 +16380,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", + "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", + "license": "MIT" + }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -16082,7 +16404,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16092,16 +16413,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", - "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz", + "integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.1", - "@typescript-eslint/parser": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1" + "@typescript-eslint/eslint-plugin": "8.35.0", + "@typescript-eslint/parser": "8.35.0", + "@typescript-eslint/utils": "8.35.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -16111,8 +16431,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/uc.micro": { @@ -16142,9 +16462,9 @@ } }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "dev": true, "license": "MIT" }, @@ -16175,6 +16495,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "license": "MIT", + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", @@ -16291,7 +16626,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16405,7 +16739,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16418,7 +16751,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -16521,6 +16853,12 @@ } } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -16797,9 +17135,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", "engines": { "node": ">=12" @@ -16809,9 +17147,9 @@ } }, "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { @@ -16838,9 +17176,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -16913,18 +17251,15 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { @@ -17063,7 +17398,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17079,9 +17413,9 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "dependencies": { - "@a2a-js/sdk": "^0.3.8", + "@a2a-js/sdk": "0.3.11", "@google-cloud/storage": "^7.16.0", "@google/gemini-cli-core": "file:../core", "express": "^5.1.0", @@ -17095,7 +17429,7 @@ "gemini-cli-a2a-server": "dist/a2a-server.mjs" }, "devDependencies": { - "@google/genai": "^1.30.0", + "@google/genai": "1.30.0", "@types/express": "^5.0.3", "@types/fs-extra": "^11.0.4", "@types/supertest": "^6.0.3", @@ -17109,6 +17443,47 @@ "node": ">=20" } }, + "packages/a2a-server/node_modules/@a2a-js/sdk": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.11.tgz", + "integrity": "sha512-pXjjlL0ZYHgAxObov1J+W3ylfQV0rOrDBB8Eo4a9eCunqs7iNW5OIfMcV8YnZQdzeVSRomj8jHeudVz0zc4RNw==", + "license": "Apache-2.0", + "dependencies": { + "uuid": "^11.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.10.2", + "@grpc/grpc-js": "^1.11.0", + "express": "^4.21.2 || ^5.1.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + }, + "@grpc/grpc-js": { + "optional": true + }, + "express": { + "optional": true + } + } + }, + "packages/a2a-server/node_modules/@a2a-js/sdk/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "packages/a2a-server/node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -17122,6 +17497,22 @@ "url": "https://dotenvx.com" } }, + "packages/a2a-server/node_modules/tar": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "packages/a2a-server/node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", @@ -17137,19 +17528,19 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@google/gemini-cli-core": "file:../core", - "@google/genai": "1.41.0", + "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.23.0", "ansi-escapes": "^7.3.0", "ansi-regex": "^6.2.2", "chalk": "^4.1.2", "cli-spinners": "^2.9.2", - "clipboardy": "^5.0.0", + "clipboardy": "~5.2.0", "color-convert": "^2.0.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", @@ -17202,14 +17593,33 @@ "node": ">=20" } }, - "packages/cli/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "packages/cli/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/cli/node_modules/clipboardy": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.2.1.tgz", + "integrity": "sha512-RWp4E/ivQAzgF4QSWA9sjeW+Bjo+U2SvebkDhNIfO7y65eGdXPUxMTdIKYsn+bxM3ItPHGm3e68Bv3fgQ3mARw==", + "license": "MIT", + "dependencies": { + "clipboard-image": "^0.1.0", + "execa": "^9.6.1", + "is-wayland": "^0.1.0", + "is-wsl": "^3.1.0", + "is64bit": "^2.0.0", + "powershell-utils": "^0.2.0" }, "engines": { "node": ">=20" @@ -17218,16 +17628,88 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/cli/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "packages/cli/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/cli/node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/cli/node_modules/tar": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { - "@a2a-js/sdk": "^0.3.8", + "@a2a-js/sdk": "0.3.11", + "@bufbuild/protobuf": "^2.11.0", "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", - "@google/genai": "1.41.0", + "@google/genai": "1.30.0", + "@grpc/grpc-js": "^1.14.3", "@iarna/toml": "^2.2.5", "@joshua.litt/get-ripgrep": "^0.0.3", "@modelcontextprotocol/sdk": "^1.23.0", @@ -17261,17 +17743,20 @@ "fdir": "^6.4.6", "fzf": "^0.5.2", "glob": "^12.0.0", - "google-auth-library": "^10.5.0", + "google-auth-library": "^9.11.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", + "ipaddr.js": "^1.9.1", "js-yaml": "^4.1.1", + "json-stable-stringify": "^1.3.0", "marked": "^15.0.12", "mime": "4.0.7", "mnemonist": "^0.40.3", "open": "^10.1.2", "picomatch": "^4.0.1", "proper-lockfile": "^4.1.2", + "puppeteer-core": "^24.0.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", @@ -17289,7 +17774,9 @@ "@google/gemini-cli-test-utils": "file:../test-utils", "@types/fast-levenshtein": "^0.0.4", "@types/js-yaml": "^4.0.9", + "@types/json-stable-stringify": "^1.1.0", "@types/picomatch": "^4.0.1", + "chrome-devtools-mcp": "^0.19.0", "msw": "^2.3.4", "typescript": "^5.3.3", "vitest": "^3.1.1" @@ -17308,6 +17795,78 @@ "node-pty": "^1.0.0" } }, + "packages/core/node_modules/@a2a-js/sdk": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.11.tgz", + "integrity": "sha512-pXjjlL0ZYHgAxObov1J+W3ylfQV0rOrDBB8Eo4a9eCunqs7iNW5OIfMcV8YnZQdzeVSRomj8jHeudVz0zc4RNw==", + "license": "Apache-2.0", + "dependencies": { + "uuid": "^11.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.10.2", + "@grpc/grpc-js": "^1.11.0", + "express": "^4.21.2 || ^5.1.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + }, + "@grpc/grpc-js": { + "optional": true + }, + "express": { + "optional": true + } + } + }, + "packages/core/node_modules/@a2a-js/sdk/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "packages/core/node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "packages/core/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "packages/core/node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -17324,14 +17883,23 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "packages/core/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", + "packages/core/node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", "engines": { - "node": ">=12.0.0" + "node": ">=12" }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "packages/core/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -17341,75 +17909,6 @@ } } }, - "packages/core/node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "packages/core/node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "packages/core/node_modules/google-auth-library": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", - "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.0.0", - "gcp-metadata": "^8.0.0", - "google-logging-utils": "^1.0.0", - "gtoken": "^8.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "packages/core/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "packages/core/node_modules/gtoken": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", - "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", - "license": "MIT", - "dependencies": { - "gaxios": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, "packages/core/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -17440,30 +17939,11 @@ "node": ">=16" } }, - "packages/core/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "packages/core/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17486,7 +17966,7 @@ }, "packages/devtools": { "name": "@google/gemini-cli-devtools", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { "ws": "^8.16.0" @@ -17501,7 +17981,7 @@ }, "packages/sdk": { "name": "@google/gemini-cli-sdk", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17518,7 +17998,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17535,7 +18015,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", diff --git a/package.json b/package.json index a7ee06676e..414f9341ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "engines": { "node": ">=20.0.0" }, @@ -14,7 +14,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", @@ -37,10 +37,13 @@ "build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:packages": "npm run build --workspaces", "build:sandbox": "node scripts/build_sandbox.js", + "build:binary": "node scripts/build_binary.js", "bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js", - "test": "npm run test --workspaces --if-present", - "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts", + "test": "npm run test --workspaces --if-present && npm run test:sea-launch", + "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", + "test:sea-launch": "vitest run sea/sea-launch.test.js", + "posttest": "npm run build", "test:always_passing_evals": "vitest run --config evals/vitest.config.ts", "test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts", "test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none", @@ -48,7 +51,7 @@ "test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests", "test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests", "test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests", - "lint": "eslint . --cache", + "lint": "eslint . --cache --max-warnings 0", "lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format", "lint:ci": "npm run lint:all", "lint:all": "node scripts/lint.js", @@ -84,7 +87,7 @@ "LICENSE" ], "devDependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", diff --git a/packages/a2a-server/GEMINI.md b/packages/a2a-server/GEMINI.md new file mode 100644 index 0000000000..34e487e3bb --- /dev/null +++ b/packages/a2a-server/GEMINI.md @@ -0,0 +1,22 @@ +# Gemini CLI A2A Server (`@google/gemini-cli-a2a-server`) + +Experimental Agent-to-Agent (A2A) server that exposes Gemini CLI capabilities +over HTTP for inter-agent communication. + +## Architecture + +- `src/agent/`: Agent session management for A2A interactions. +- `src/commands/`: CLI command definitions for the A2A server binary. +- `src/config/`: Server configuration. +- `src/http/`: HTTP server and route handlers. +- `src/persistence/`: Session and state persistence. +- `src/utils/`: Shared utility functions. +- `src/types.ts`: Shared type definitions. + +## Running + +- Binary entry point: `gemini-cli-a2a-server` + +## Testing + +- Run tests: `npm test -w @google/gemini-cli-a2a-server` diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index bc85e51bc6..5257e56240 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "description": "Gemini CLI A2A Server", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "dependencies": { - "@a2a-js/sdk": "^0.3.8", + "@a2a-js/sdk": "0.3.11", "@google-cloud/storage": "^7.16.0", "@google/gemini-cli-core": "file:../core", "express": "^5.1.0", @@ -36,7 +36,7 @@ "winston": "^3.17.0" }, "devDependencies": { - "@google/genai": "^1.30.0", + "@google/genai": "1.30.0", "@types/express": "^5.0.3", "@types/fs-extra": "^11.0.4", "@types/supertest": "^6.0.3", diff --git a/packages/a2a-server/src/agent/executor.test.ts b/packages/a2a-server/src/agent/executor.test.ts new file mode 100644 index 0000000000..2b77f3006c --- /dev/null +++ b/packages/a2a-server/src/agent/executor.test.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { CoderAgentExecutor } from './executor.js'; +import type { + ExecutionEventBus, + RequestContext, + TaskStore, +} from '@a2a-js/sdk/server'; +import { EventEmitter } from 'node:events'; +import { requestStorage } from '../http/requestStorage.js'; + +// Mocks for constructor dependencies +vi.mock('../config/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ + getSessionId: () => 'test-session', + getTargetDir: () => '/tmp', + getCheckpointingEnabled: () => false, + }), + loadEnvironment: vi.fn(), + setTargetDir: vi.fn().mockReturnValue('/tmp'), +})); + +vi.mock('../config/settings.js', () => ({ + loadSettings: vi.fn().mockReturnValue({}), +})); + +vi.mock('../config/extension.js', () => ({ + loadExtensions: vi.fn().mockReturnValue([]), +})); + +vi.mock('../http/requestStorage.js', () => ({ + requestStorage: { + getStore: vi.fn(), + }, +})); + +vi.mock('./task.js', () => { + const mockTaskInstance = (taskId: string, contextId: string) => ({ + id: taskId, + contextId, + taskState: 'working', + acceptUserMessage: vi + .fn() + .mockImplementation(async function* (context, aborted) { + const isConfirmation = ( + context.userMessage.parts as Array<{ kind: string }> + ).some((p) => p.kind === 'confirmation'); + // Hang only for main user messages (text), allow confirmations to finish quickly + if (!isConfirmation && aborted) { + await new Promise((resolve) => { + aborted.addEventListener('abort', resolve, { once: true }); + }); + } + yield { type: 'content', value: 'hello' }; + }), + acceptAgentMessage: vi.fn().mockResolvedValue(undefined), + scheduleToolCalls: vi.fn().mockResolvedValue(undefined), + waitForPendingTools: vi.fn().mockResolvedValue(undefined), + getAndClearCompletedTools: vi.fn().mockReturnValue([]), + addToolResponsesToHistory: vi.fn(), + sendCompletedToolsToLlm: vi.fn().mockImplementation(async function* () {}), + cancelPendingTools: vi.fn(), + setTaskStateAndPublishUpdate: vi.fn(), + dispose: vi.fn(), + getMetadata: vi.fn().mockResolvedValue({}), + geminiClient: { + initialize: vi.fn().mockResolvedValue(undefined), + }, + toSDKTask: () => ({ + id: taskId, + contextId, + kind: 'task', + status: { state: 'working', timestamp: new Date().toISOString() }, + metadata: {}, + history: [], + artifacts: [], + }), + }); + + const MockTask = vi.fn().mockImplementation(mockTaskInstance); + (MockTask as unknown as { create: Mock }).create = vi + .fn() + .mockImplementation(async (taskId: string, contextId: string) => + mockTaskInstance(taskId, contextId), + ); + + return { Task: MockTask }; +}); + +describe('CoderAgentExecutor', () => { + let executor: CoderAgentExecutor; + let mockTaskStore: TaskStore; + let mockEventBus: ExecutionEventBus; + + beforeEach(() => { + vi.clearAllMocks(); + mockTaskStore = { + save: vi.fn().mockResolvedValue(undefined), + load: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + } as unknown as TaskStore; + + mockEventBus = new EventEmitter() as unknown as ExecutionEventBus; + mockEventBus.publish = vi.fn(); + mockEventBus.finished = vi.fn(); + + executor = new CoderAgentExecutor(mockTaskStore); + }); + + it('should distinguish between primary and secondary execution', async () => { + const taskId = 'test-task'; + const contextId = 'test-context'; + + const mockSocket = new EventEmitter(); + const requestContext = { + userMessage: { + messageId: 'msg-1', + taskId, + contextId, + parts: [{ kind: 'text', text: 'hi' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + // Mock requestStorage for primary + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket }, + }); + + // First execution (Primary) + const primaryPromise = executor.execute(requestContext, mockEventBus); + + // Give it enough time to reach line 490 in executor.ts + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect( + ( + executor as unknown as { executingTasks: Set } + ).executingTasks.has(taskId), + ).toBe(true); + const wrapper = executor.getTask(taskId); + expect(wrapper).toBeDefined(); + + // Mock requestStorage for secondary + const secondarySocket = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: secondarySocket }, + }); + + const secondaryRequestContext = { + userMessage: { + messageId: 'msg-2', + taskId, + contextId, + parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + const secondaryPromise = executor.execute( + secondaryRequestContext, + mockEventBus, + ); + + // Secondary execution should NOT add to executingTasks (already there) + // and should return early after its loop + await secondaryPromise; + + // Task should still be in executingTasks and NOT disposed + expect( + ( + executor as unknown as { executingTasks: Set } + ).executingTasks.has(taskId), + ).toBe(true); + expect(wrapper?.task.dispose).not.toHaveBeenCalled(); + + // Now simulate secondary socket closure - it should NOT affect primary + secondarySocket.emit('end'); + expect( + ( + executor as unknown as { executingTasks: Set } + ).executingTasks.has(taskId), + ).toBe(true); + expect(wrapper?.task.dispose).not.toHaveBeenCalled(); + + // Set to terminal state to verify disposal on finish + wrapper!.task.taskState = 'completed'; + + // Now close primary socket + mockSocket.emit('end'); + + await primaryPromise; + + expect( + ( + executor as unknown as { executingTasks: Set } + ).executingTasks.has(taskId), + ).toBe(false); + expect(wrapper?.task.dispose).toHaveBeenCalled(); + }); + + it('should evict task from cache when it reaches terminal state', async () => { + const taskId = 'test-task-terminal'; + const contextId = 'test-context'; + + const mockSocket = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket }, + }); + + const requestContext = { + userMessage: { + messageId: 'msg-1', + taskId, + contextId, + parts: [{ kind: 'text', text: 'hi' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + const primaryPromise = executor.execute(requestContext, mockEventBus); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const wrapper = executor.getTask(taskId)!; + expect(wrapper).toBeDefined(); + // Simulate terminal state + wrapper.task.taskState = 'completed'; + + // Finish primary execution + mockSocket.emit('end'); + await primaryPromise; + + expect(executor.getTask(taskId)).toBeUndefined(); + expect(wrapper.task.dispose).toHaveBeenCalled(); + }); +}); diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts index e2287a2562..dbb8269376 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -12,23 +12,22 @@ import type { RequestContext, ExecutionEventBus, } from '@a2a-js/sdk/server'; -import type { ToolCallRequestInfo, Config } from '@google/gemini-cli-core'; import { GeminiEventType, SimpleExtensionLoader, + type ToolCallRequestInfo, + type Config, } from '@google/gemini-cli-core'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; -import type { - StateChange, - AgentSettings, - PersistedStateMetadata, -} from '../types.js'; import { CoderAgentEvent, getPersistedState, setPersistedState, + type StateChange, + type AgentSettings, + type PersistedStateMetadata, getContextIdFromMetadata, getAgentSettingsFromMetadata, } from '../types.js'; @@ -253,6 +252,10 @@ export class CoderAgentExecutor implements AgentExecutor { ); await this.taskStore?.save(wrapper.toSDKTask()); logger.info(`[CoderAgentExecutor] Task ${taskId} state CANCELED saved.`); + + // Cleanup listener subscriptions to avoid memory leaks. + wrapper.task.dispose(); + this.tasks.delete(taskId); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -321,23 +324,26 @@ export class CoderAgentExecutor implements AgentExecutor { if (store) { // Grab the raw socket from the request object const socket = store.req.socket; - const onClientEnd = () => { + const onSocketEnd = () => { logger.info( - `[CoderAgentExecutor] Client socket closed for task ${taskId}. Cancelling execution.`, + `[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`, ); if (!abortController.signal.aborted) { abortController.abort(); } // Clean up the listener to prevent memory leaks - socket.removeListener('close', onClientEnd); + socket.removeListener('end', onSocketEnd); }; // Listen on the socket's 'end' event (remote closed the connection) - socket.on('end', onClientEnd); + socket.on('end', onSocketEnd); + socket.once('close', () => { + socket.removeListener('end', onSocketEnd); + }); // It's also good practice to remove the listener if the task completes successfully abortSignal.addEventListener('abort', () => { - socket.removeListener('end', onClientEnd); + socket.removeListener('end', onSocketEnd); }); logger.info( `[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`, @@ -458,6 +464,26 @@ export class CoderAgentExecutor implements AgentExecutor { return; } + // Check if this is the primary/initial execution for this task + const isPrimaryExecution = !this.executingTasks.has(taskId); + + if (!isPrimaryExecution) { + logger.info( + `[CoderAgentExecutor] Primary execution already active for task ${taskId}. Starting secondary loop for message ${userMessage.messageId}.`, + ); + currentTask.eventBus = eventBus; + for await (const _ of currentTask.acceptUserMessage( + requestContext, + abortController.signal, + )) { + logger.info( + `[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`, + ); + } + // End this execution-- the original/source will be resumed. + return; + } + logger.info( `[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`, ); @@ -599,18 +625,30 @@ export class CoderAgentExecutor implements AgentExecutor { } } } finally { - this.executingTasks.delete(taskId); - logger.info( - `[CoderAgentExecutor] Saving final state for task ${taskId}.`, - ); - try { - await this.taskStore?.save(wrapper.toSDKTask()); - logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`); - } catch (saveError) { - logger.error( - `[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`, - saveError, + if (isPrimaryExecution) { + this.executingTasks.delete(taskId); + logger.info( + `[CoderAgentExecutor] Saving final state for task ${taskId}.`, ); + try { + await this.taskStore?.save(wrapper.toSDKTask()); + logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`); + } catch (saveError) { + logger.error( + `[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`, + saveError, + ); + } + + if ( + ['canceled', 'failed', 'completed'].includes(currentTask.taskState) + ) { + logger.info( + `[CoderAgentExecutor] Task ${taskId} reached terminal state ${currentTask.taskState}. Evicting and disposing.`, + ); + wrapper.task.dispose(); + this.tasks.delete(taskId); + } } } } diff --git a/packages/a2a-server/src/agent/task-event-driven.test.ts b/packages/a2a-server/src/agent/task-event-driven.test.ts new file mode 100644 index 0000000000..86436fa811 --- /dev/null +++ b/packages/a2a-server/src/agent/task-event-driven.test.ts @@ -0,0 +1,655 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { Task } from './task.js'; +import { + type Config, + MessageBusType, + ToolConfirmationOutcome, + ApprovalMode, + Scheduler, + type MessageBus, +} from '@google/gemini-cli-core'; +import { createMockConfig } from '../utils/testing_utils.js'; +import type { ExecutionEventBus } from '@a2a-js/sdk/server'; + +describe('Task Event-Driven Scheduler', () => { + let mockConfig: Config; + let mockEventBus: ExecutionEventBus; + let messageBus: MessageBus; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = createMockConfig({ + isEventDrivenSchedulerEnabled: () => true, + }) as Config; + messageBus = mockConfig.messageBus; + mockEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + }); + + it('should instantiate Scheduler when enabled', () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + expect(task.scheduler).toBeInstanceOf(Scheduler); + }); + + it('should subscribe to TOOL_CALLS_UPDATE and map status changes', async () => { + // @ts-expect-error - Calling private constructor + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'executing', + }; + + // Simulate MessageBus event + // Simulate MessageBus event + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + if (!handler) { + throw new Error('TOOL_CALLS_UPDATE handler not found'); + } + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + }); + + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + status: expect.objectContaining({ + state: 'submitted', // initial task state + }), + metadata: expect.objectContaining({ + coderAgent: expect.objectContaining({ + kind: 'tool-call-update', + }), + }), + }), + ); + }); + + it('should handle tool confirmations by publishing to MessageBus', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-1', + confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, + }; + + // Simulate MessageBus event to stash the correlationId + // Simulate MessageBus event + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + if (!handler) { + throw new Error('TOOL_CALLS_UPDATE handler not found'); + } + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + }); + + // Simulate A2A client confirmation + const part = { + kind: 'data', + data: { + callId: '1', + outcome: 'proceed_once', + }, + }; + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart(part); + expect(handled).toBe(true); + + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-1', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedOnce, + }), + ); + }); + + it('should handle Rejection (Cancel) and Modification (ModifyWithEditor)', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-1', + confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); + + // Simulate Rejection (Cancel) + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'cancel' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-1', + confirmed: false, + }), + ); + + const toolCall2 = { + request: { callId: '2', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-2', + confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, + }; + handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall2] }); + + // Simulate ModifyWithEditor + const handled2 = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '2', outcome: 'modify_with_editor' }, + }); + expect(handled2).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-2', + confirmed: false, + outcome: ToolConfirmationOutcome.ModifyWithEditor, + payload: undefined, + }), + ); + }); + + it('should handle MCP Server tool operations correctly', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'call_mcp_tool', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-mcp-1', + confirmationDetails: { + type: 'mcp', + title: 'MCP Server Operation', + prompt: 'test_mcp', + }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); + + // Simulate ProceedOnce for MCP + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_once' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-mcp-1', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedOnce, + }), + ); + }); + + it('should handle MCP Server tool ProceedAlwaysServer outcome', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'call_mcp_tool', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-mcp-2', + confirmationDetails: { + type: 'mcp', + title: 'MCP Server Operation', + prompt: 'test_mcp', + }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_always_server' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-mcp-2', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedAlwaysServer, + }), + ); + }); + + it('should handle MCP Server tool ProceedAlwaysTool outcome', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'call_mcp_tool', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-mcp-3', + confirmationDetails: { + type: 'mcp', + title: 'MCP Server Operation', + prompt: 'test_mcp', + }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_always_tool' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-mcp-3', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedAlwaysTool, + }), + ); + }); + + it('should handle MCP Server tool ProceedAlwaysAndSave outcome', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'call_mcp_tool', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-mcp-4', + confirmationDetails: { + type: 'mcp', + title: 'MCP Server Operation', + prompt: 'test_mcp', + }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_always_and_save' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-mcp-4', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave, + }), + ); + }); + + it('should execute without confirmation in YOLO mode and not transition to input-required', async () => { + // Enable YOLO mode + const yoloConfig = createMockConfig({ + isEventDrivenSchedulerEnabled: () => true, + getApprovalMode: () => ApprovalMode.YOLO, + }) as Config; + const yoloMessageBus = yoloConfig.messageBus; + + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus); + task.setTaskStateAndPublishUpdate = vi.fn(); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-1', + confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, + }; + + const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); + + // Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly + expect(yoloMessageBus.publish).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + }), + ); + + // Should NOT transition to input-required since it was auto-approved + expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith( + 'input-required', + expect.anything(), + undefined, + undefined, + true, + ); + }); + + it('should handle output updates via the message bus', async () => { + // @ts-expect-error - Calling private constructor + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'executing', + liveOutput: 'chunk1', + }; + + // Simulate MessageBus event + // Simulate MessageBus event + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + if (!handler) { + throw new Error('TOOL_CALLS_UPDATE handler not found'); + } + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + }); + + // Should publish artifact update for output + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'artifact-update', + artifact: expect.objectContaining({ + artifactId: 'tool-1-output', + parts: [{ kind: 'text', text: 'chunk1' }], + }), + }), + ); + }); + + it('should complete artifact creation without hanging', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCallId = 'create-file-123'; + task['_registerToolCall'](toolCallId, 'executing'); + + const toolCall = { + request: { + callId: toolCallId, + name: 'writeFile', + args: { path: 'test.sh' }, + }, + status: 'success', + result: { ok: true }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] }); + + // The tool should be complete and registered appropriately, eventually + // triggering the toolCompletionPromise resolution when all clear. + const internalTask = task as unknown as { + completedToolCalls: unknown[]; + pendingToolCalls: Map; + }; + expect(internalTask.completedToolCalls.length).toBe(1); + expect(internalTask.pendingToolCalls.size).toBe(0); + }); + + it('should preserve messageId across multiple text chunks to prevent UI duplication', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + // Initialize the ID for the first turn (happens internally upon LLM stream) + task.currentAgentMessageId = 'test-id-123'; + + // Simulate sending multiple text chunks + task._sendTextContent('chunk 1'); + task._sendTextContent('chunk 2'); + + // Both text contents should have been published with the same messageId + const textCalls = (mockEventBus.publish as Mock).mock.calls.filter( + (call) => call[0].status?.message?.kind === 'message', + ); + expect(textCalls.length).toBe(2); + expect(textCalls[0][0].status.message.messageId).toBe('test-id-123'); + expect(textCalls[1][0].status.message.messageId).toBe('test-id-123'); + + // Simulate starting a new turn by calling getAndClearCompletedTools + // (which precedes sendCompletedToolsToLlm where a new ID is minted) + task.getAndClearCompletedTools(); + + // sendCompletedToolsToLlm internally rolls the ID forward. + // Simulate what sendCompletedToolsToLlm does: + const internalTask = task as unknown as { + setTaskStateAndPublishUpdate: (state: string, change: unknown) => void; + }; + internalTask.setTaskStateAndPublishUpdate('working', {}); + + // Simulate what sendCompletedToolsToLlm does: generate a new UUID for the next turn + task.currentAgentMessageId = 'test-id-456'; + + task._sendTextContent('chunk 3'); + + const secondTurnCalls = (mockEventBus.publish as Mock).mock.calls.filter( + (call) => call[0].status?.message?.messageId === 'test-id-456', + ); + expect(secondTurnCalls.length).toBe(1); + expect(secondTurnCalls[0][0].status.message.parts[0].text).toBe('chunk 3'); + }); + + it('should handle parallel tool calls correctly', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall1 = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-1', + confirmationDetails: { type: 'info', title: 'test 1', prompt: 'test 1' }, + }; + + const toolCall2 = { + request: { callId: '2', name: 'pwd', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-2', + confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + // Publish update for both tool calls simultaneously + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall1, toolCall2], + }); + + // Confirm first tool call + const handled1 = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_once' }, + }); + expect(handled1).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-1', + confirmed: true, + }), + ); + + // Confirm second tool call + const handled2 = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '2', outcome: 'cancel' }, + }); + expect(handled2).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-2', + confirmed: false, + }), + ); + }); + + it('should wait for executing tools before transitioning to input-required state', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + task.setTaskStateAndPublishUpdate = vi.fn(); + + // Register tool 1 as executing + task['_registerToolCall']('1', 'executing'); + + const toolCall1 = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'executing', + }; + + const toolCall2 = { + request: { callId: '2', name: 'pwd', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-2', + confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall1, toolCall2], + }); + + // Should NOT transition to input-required yet + expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith( + 'input-required', + expect.anything(), + undefined, + undefined, + true, + ); + + // Complete tool 1 + const toolCall1Complete = { + ...toolCall1, + status: 'success', + result: { ok: true }, + }; + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall1Complete, toolCall2], + }); + + // Now it should transition + expect(task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith( + 'input-required', + expect.anything(), + undefined, + undefined, + true, + ); + }); + + it('should ignore confirmations for unknown tool calls', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: 'unknown-id', outcome: 'proceed_once' }, + }); + + // Should return false for unhandled tool call + expect(handled).toBe(false); + + // Should not publish anything to the message bus + expect(messageBus.publish).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index 81987a780b..26039ae3aa 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -4,27 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mock, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { Task } from './task.js'; -import type { - ToolCall, - Config, - ToolCallRequestInfo, - GitService, - CompletedToolCall, -} from '@google/gemini-cli-core'; import { GeminiEventType, - ApprovalMode, - ToolConfirmationOutcome, + type Config, + type ToolCallRequestInfo, + type GitService, + type CompletedToolCall, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; @@ -391,188 +378,6 @@ describe('Task', () => { ); }); - describe('_schedulerToolCallsUpdate', () => { - let task: Task; - type SpyInstance = ReturnType; - let setTaskStateAndPublishUpdateSpy: SpyInstance; - let mockConfig: Config; - let mockEventBus: ExecutionEventBus; - - beforeEach(() => { - mockConfig = createMockConfig() as Config; - mockEventBus = { - publish: vi.fn(), - on: vi.fn(), - off: vi.fn(), - once: vi.fn(), - removeAllListeners: vi.fn(), - finished: vi.fn(), - }; - - // @ts-expect-error - Calling private constructor - task = new Task('task-id', 'context-id', mockConfig, mockEventBus); - - // Spy on the method we want to check calls for - setTaskStateAndPublishUpdateSpy = vi.spyOn( - task, - 'setTaskStateAndPublishUpdate', - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should set state to input-required when a tool is awaiting approval and none are executing', () => { - const toolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - ] as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - // The last call should be the final state update - expect(setTaskStateAndPublishUpdateSpy).toHaveBeenLastCalledWith( - 'input-required', - { kind: 'state-change' }, - undefined, - undefined, - true, // final: true - ); - }); - - it('should NOT set state to input-required if a tool is awaiting approval but another is executing', () => { - const toolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - { request: { callId: '2' }, status: 'executing' }, - ] as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - // It will be called for status updates, but not with final: true - const finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( - (call) => call[4] === true, - ); - expect(finalCall).toBeUndefined(); - }); - - it('should set state to input-required once an executing tool finishes, leaving one awaiting approval', () => { - const initialToolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - { request: { callId: '2' }, status: 'executing' }, - ] as ToolCall[]; - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(initialToolCalls); - - // No final call yet - let finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( - (call) => call[4] === true, - ); - expect(finalCall).toBeUndefined(); - - // Now, the executing tool finishes. The scheduler would call _resolveToolCall for it. - // @ts-expect-error - Calling private method - task._resolveToolCall('2'); - - // Then another update comes in for the awaiting tool (e.g., a re-check) - const subsequentToolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - ] as ToolCall[]; - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(subsequentToolCalls); - - // NOW we should get the final call - finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( - (call) => call[4] === true, - ); - expect(finalCall).toBeDefined(); - expect(finalCall?.[0]).toBe('input-required'); - }); - - it('should NOT set state to input-required if skipFinalTrueAfterInlineEdit is true', () => { - task.skipFinalTrueAfterInlineEdit = true; - const toolCalls = [ - { request: { callId: '1' }, status: 'awaiting_approval' }, - ] as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - const finalCall = setTaskStateAndPublishUpdateSpy.mock.calls.find( - (call) => call[4] === true, - ); - expect(finalCall).toBeUndefined(); - }); - - describe('auto-approval', () => { - it('should auto-approve tool calls when autoExecute is true', () => { - task.autoExecute = true; - const onConfirmSpy = vi.fn(); - const toolCalls = [ - { - request: { callId: '1' }, - status: 'awaiting_approval', - confirmationDetails: { - type: 'edit', - onConfirm: onConfirmSpy, - }, - }, - ] as unknown as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - expect(onConfirmSpy).toHaveBeenCalledWith( - ToolConfirmationOutcome.ProceedOnce, - ); - }); - - it('should auto-approve tool calls when approval mode is YOLO', () => { - (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO); - task.autoExecute = false; - const onConfirmSpy = vi.fn(); - const toolCalls = [ - { - request: { callId: '1' }, - status: 'awaiting_approval', - confirmationDetails: { - type: 'edit', - onConfirm: onConfirmSpy, - }, - }, - ] as unknown as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - expect(onConfirmSpy).toHaveBeenCalledWith( - ToolConfirmationOutcome.ProceedOnce, - ); - }); - - it('should NOT auto-approve when autoExecute is false and mode is not YOLO', () => { - task.autoExecute = false; - (mockConfig.getApprovalMode as Mock).mockReturnValue( - ApprovalMode.DEFAULT, - ); - const onConfirmSpy = vi.fn(); - const toolCalls = [ - { - request: { callId: '1' }, - status: 'awaiting_approval', - confirmationDetails: { onConfirm: onConfirmSpy }, - }, - ] as unknown as ToolCall[]; - - // @ts-expect-error - Calling private method - task._schedulerToolCallsUpdate(toolCalls); - - expect(onConfirmSpy).not.toHaveBeenCalled(); - }); - }); - }); - describe('currentPromptId and promptCount', () => { it('should correctly initialize and update promptId and promptCount', async () => { const mockConfig = createMockConfig(); diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index c91ef72781..a76054263f 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -5,7 +5,8 @@ */ import { - CoreToolScheduler, + type AgentLoopContext, + Scheduler, type GeminiClient, GeminiEventType, ToolConfirmationOutcome, @@ -27,11 +28,20 @@ import { type ToolCallConfirmationDetails, type Config, type UserTierId, + type ToolLiveOutput, + type AnsiLine, type AnsiOutput, + type AnsiToken, + isSubagentProgress, EDIT_TOOL_NAMES, processRestorableToolCalls, + MessageBusType, + type ToolCallsUpdateMessage, } from '@google/gemini-cli-core'; -import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server'; +import { + type ExecutionEventBus, + type RequestContext, +} from '@a2a-js/sdk/server'; import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent, @@ -44,66 +54,48 @@ import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { CoderAgentEvent } from '../types.js'; -import type { - CoderAgentMessage, - StateChange, - ToolCallUpdate, - TextContent, - TaskMetadata, - Thought, - ThoughtSummary, - Citation, +import { + CoderAgentEvent, + type CoderAgentMessage, + type StateChange, + type ToolCallUpdate, + type TextContent, + type TaskMetadata, + type Thought, + type ThoughtSummary, + type Citation, } from '../types.js'; import type { PartUnion, Part as genAiPart } from '@google/genai'; type UnionKeys = T extends T ? keyof T : never; -type ConfirmationType = ToolCallConfirmationDetails['type']; - -const VALID_CONFIRMATION_TYPES: readonly ConfirmationType[] = [ - 'edit', - 'exec', - 'mcp', - 'info', - 'ask_user', - 'exit_plan_mode', -] as const; - -function isToolCallConfirmationDetails( - value: unknown, -): value is ToolCallConfirmationDetails { - if ( - typeof value !== 'object' || - value === null || - !('onConfirm' in value) || - typeof value.onConfirm !== 'function' || - !('type' in value) || - typeof value.type !== 'string' - ) { - return false; - } - return (VALID_CONFIRMATION_TYPES as readonly string[]).includes(value.type); -} - export class Task { id: string; contextId: string; - scheduler: CoreToolScheduler; + scheduler: Scheduler; config: Config; geminiClient: GeminiClient; pendingToolConfirmationDetails: Map; + pendingCorrelationIds: Map = new Map(); taskState: TaskState; eventBus?: ExecutionEventBus; completedToolCalls: CompletedToolCall[]; + processedToolCallIds: Set = new Set(); skipFinalTrueAfterInlineEdit = false; modelInfo?: string; currentPromptId: string | undefined; + currentAgentMessageId = uuidv4(); promptCount = 0; autoExecute: boolean; + private get isYoloMatch(): boolean { + return ( + this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO + ); + } // For tool waiting logic private pendingToolCalls: Map = new Map(); //toolCallId --> status + private toolsAlreadyConfirmed: Set = new Set(); private toolCompletionPromise?: Promise; private toolCompletionNotifier?: { resolve: () => void; @@ -120,8 +112,11 @@ export class Task { this.id = id; this.contextId = contextId; this.config = config; - this.scheduler = this.createScheduler(); - this.geminiClient = this.config.getGeminiClient(); + + this.scheduler = this.setupEventDrivenScheduler(); + + const loopContext: AgentLoopContext = this.config; + this.geminiClient = loopContext.geminiClient; this.pendingToolConfirmationDetails = new Map(); this.taskState = 'submitted'; this.eventBus = eventBus; @@ -150,7 +145,8 @@ export class Task { // process. This is not scoped to the individual task but reflects the global connection // state managed within the @gemini-cli/core module. async getMetadata(): Promise { - const toolRegistry = this.config.getToolRegistry(); + const loopContext: AgentLoopContext = this.config; + const toolRegistry = loopContext.toolRegistry; const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {}; const serverStatuses = getAllMCPServerStatuses(); const servers = Object.keys(mcpServers).map((serverName) => ({ @@ -220,7 +216,7 @@ export class Task { logger.info( `[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`, ); - return this.toolCompletionPromise; + await this.toolCompletionPromise; } cancelPendingTools(reason: string): void { @@ -233,6 +229,9 @@ export class Task { this.toolCompletionNotifier.reject(new Error(reason)); } this.pendingToolCalls.clear(); + this.pendingCorrelationIds.clear(); + + this.scheduler.cancelAll(); // Reset the promise for any future operations, ensuring it's in a clean state. this._resetToolCompletionPromise(); } @@ -245,7 +244,7 @@ export class Task { kind: 'message', role, parts: [{ kind: 'text', text }], - messageId: uuidv4(), + messageId: role === 'agent' ? this.currentAgentMessageId : uuidv4(), taskId: this.id, contextId: this.contextId, }; @@ -333,15 +332,22 @@ export class Task { private _schedulerOutputUpdate( toolCallId: string, - outputChunk: string | AnsiOutput, + outputChunk: ToolLiveOutput, ): void { let outputAsText: string; if (typeof outputChunk === 'string') { outputAsText = outputChunk; - } else { - outputAsText = outputChunk - .map((line) => line.map((token) => token.text).join('')) + } else if (isSubagentProgress(outputChunk)) { + outputAsText = JSON.stringify(outputChunk); + } else if (Array.isArray(outputChunk)) { + const ansiOutput: AnsiOutput = outputChunk; + outputAsText = ansiOutput + .map((line: AnsiLine) => + line.map((token: AnsiToken) => token.text).join(''), + ) .join('\n'); + } else { + outputAsText = String(outputChunk); } logger.info( @@ -370,104 +376,156 @@ export class Task { this.eventBus?.publish(artifactEvent); } - private async _schedulerAllToolCallsComplete( - completedToolCalls: CompletedToolCall[], - ): Promise { - logger.info( - '[Task] All tool calls completed by scheduler (batch):', - completedToolCalls.map((tc) => tc.request.callId), - ); - this.completedToolCalls.push(...completedToolCalls); - completedToolCalls.forEach((tc) => { - this._resolveToolCall(tc.request.callId); + private messageBusListener?: (message: ToolCallsUpdateMessage) => void; + + private setupEventDrivenScheduler(): Scheduler { + const loopContext: AgentLoopContext = this.config; + const messageBus = loopContext.messageBus; + const scheduler = new Scheduler({ + schedulerId: this.id, + context: this.config, + messageBus, + getPreferredEditor: () => DEFAULT_GUI_EDITOR, }); + + this.messageBusListener = this.handleEventDrivenToolCallsUpdate.bind(this); + messageBus.subscribe( + MessageBusType.TOOL_CALLS_UPDATE, + this.messageBusListener, + ); + + return scheduler; } - private _schedulerToolCallsUpdate(toolCalls: ToolCall[]): void { - logger.info( - '[Task] Scheduler tool calls updated:', - toolCalls.map((tc) => `${tc.request.callId} (${tc.status})`), - ); - - // Update state and send continuous, non-final updates - toolCalls.forEach((tc) => { - const previousStatus = this.pendingToolCalls.get(tc.request.callId); - const hasChanged = previousStatus !== tc.status; - - // Resolve tool call if it has reached a terminal state - if (['success', 'error', 'cancelled'].includes(tc.status)) { - this._resolveToolCall(tc.request.callId); - } else { - // This will update the map - this._registerToolCall(tc.request.callId, tc.status); - } - - if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { - const details = tc.confirmationDetails; - if (isToolCallConfirmationDetails(details)) { - this.pendingToolConfirmationDetails.set(tc.request.callId, details); - } - } - - // Only send an update if the status has actually changed. - if (hasChanged) { - const coderAgentMessage: CoderAgentMessage = - tc.status === 'awaiting_approval' - ? { kind: CoderAgentEvent.ToolCallConfirmationEvent } - : { kind: CoderAgentEvent.ToolCallUpdateEvent }; - const message = this.toolStatusMessage(tc, this.id, this.contextId); - - const event = this._createStatusUpdateEvent( - this.taskState, - coderAgentMessage, - message, - false, // Always false for these continuous updates - ); - this.eventBus?.publish(event); - } - }); - - if ( - this.autoExecute || - this.config.getApprovalMode() === ApprovalMode.YOLO - ) { - logger.info( - '[Task] ' + - (this.autoExecute ? '' : 'YOLO mode enabled. ') + - 'Auto-approving all tool calls.', + dispose(): void { + if (this.messageBusListener) { + const loopContext: AgentLoopContext = this.config; + loopContext.messageBus.unsubscribe( + MessageBusType.TOOL_CALLS_UPDATE, + this.messageBusListener, ); - toolCalls.forEach((tc: ToolCall) => { - if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { - const details = tc.confirmationDetails; - if (isToolCallConfirmationDetails(details)) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - details.onConfirm(ToolConfirmationOutcome.ProceedOnce); - this.pendingToolConfirmationDetails.delete(tc.request.callId); - } - } - }); + this.messageBusListener = undefined; + } + + this.scheduler.dispose(); + } + + private handleEventDrivenToolCallsUpdate( + event: ToolCallsUpdateMessage, + ): void { + if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) { return; } - const allPendingStatuses = Array.from(this.pendingToolCalls.values()); - const isAwaitingApproval = allPendingStatuses.some( - (status) => status === 'awaiting_approval', - ); - const isExecuting = allPendingStatuses.some( - (status) => status === 'executing', - ); + const toolCalls = event.toolCalls; + + toolCalls.forEach((tc) => { + this.handleEventDrivenToolCall(tc); + }); + + this.checkInputRequiredState(); + } + + private handleEventDrivenToolCall(tc: ToolCall): void { + const callId = tc.request.callId; + + // Do not process events for tools that have already been finalized. + // This prevents duplicate completions if the state manager emits a snapshot containing + // already resolved tools whose IDs were removed from pendingToolCalls. + if ( + this.processedToolCallIds.has(callId) || + this.completedToolCalls.some((c) => c.request.callId === callId) + ) { + return; + } + + const previousStatus = this.pendingToolCalls.get(callId); + const hasChanged = previousStatus !== tc.status; + + // 1. Handle Output + if (tc.status === 'executing' && tc.liveOutput) { + this._schedulerOutputUpdate(callId, tc.liveOutput); + } + + // 2. Handle terminal states + if ( + tc.status === 'success' || + tc.status === 'error' || + tc.status === 'cancelled' + ) { + this.toolsAlreadyConfirmed.delete(callId); + if (hasChanged) { + logger.info( + `[Task] Tool call ${callId} completed with status: ${tc.status}`, + ); + this.completedToolCalls.push(tc); + this._resolveToolCall(callId); + } + } else { + // Keep track of pending tools + this._registerToolCall(callId, tc.status); + } + + // 3. Handle Confirmation Stash + if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { + const details = tc.confirmationDetails; + + if (tc.correlationId) { + this.pendingCorrelationIds.set(callId, tc.correlationId); + } + + this.pendingToolConfirmationDetails.set(callId, { + ...details, + onConfirm: async () => {}, + } as ToolCallConfirmationDetails); + } + + // 4. Publish Status Updates to A2A event bus + if (hasChanged) { + const coderAgentMessage: CoderAgentMessage = + tc.status === 'awaiting_approval' + ? { kind: CoderAgentEvent.ToolCallConfirmationEvent } + : { kind: CoderAgentEvent.ToolCallUpdateEvent }; + + const message = this.toolStatusMessage(tc, this.id, this.contextId); + const statusUpdate = this._createStatusUpdateEvent( + this.taskState, + coderAgentMessage, + message, + false, + ); + this.eventBus?.publish(statusUpdate); + } + } + + private checkInputRequiredState(): void { + if (this.isYoloMatch) { + return; + } + + // 6. Handle Input Required State + let isAwaitingApproval = false; + let isExecuting = false; + + for (const [callId, status] of this.pendingToolCalls.entries()) { + if (status === 'executing' || status === 'scheduled') { + isExecuting = true; + } else if ( + status === 'awaiting_approval' && + !this.toolsAlreadyConfirmed.has(callId) + ) { + isAwaitingApproval = true; + } + } - // The turn is complete and requires user input if at least one tool - // is waiting for the user's decision, and no other tool is actively - // running in the background. if ( isAwaitingApproval && !isExecuting && !this.skipFinalTrueAfterInlineEdit ) { this.skipFinalTrueAfterInlineEdit = false; + const wasAlreadyInputRequired = this.taskState === 'input-required'; - // We don't need to send another message, just a final status update. this.setTaskStateAndPublishUpdate( 'input-required', { kind: CoderAgentEvent.StateChangeEvent }, @@ -475,18 +533,13 @@ export class Task { undefined, /*final*/ true, ); - } - } - private createScheduler(): CoreToolScheduler { - const scheduler = new CoreToolScheduler({ - outputUpdateHandler: this._schedulerOutputUpdate.bind(this), - onAllToolCallsComplete: this._schedulerAllToolCallsComplete.bind(this), - onToolCallsUpdate: this._schedulerToolCallsUpdate.bind(this), - getPreferredEditor: () => DEFAULT_GUI_EDITOR, - config: this.config, - }); - return scheduler; + // Unblock waitForPendingTools to correctly end the executor loop and release the HTTP response stream. + // The IDE client will open a new stream with the confirmation reply. + if (!wasAlreadyInputRequired && this.toolCompletionNotifier) { + this.toolCompletionNotifier.resolve(); + } + } } private _pickFields< @@ -699,7 +752,16 @@ export class Task { }; this.setTaskStateAndPublishUpdate('working', stateChange); - await this.scheduler.schedule(updatedRequests, abortSignal); + // Pre-register tools to ensure waitForPendingTools sees them as pending + // before the async scheduler enqueues them and fires the event bus update. + for (const req of updatedRequests) { + if (!this.pendingToolCalls.has(req.callId)) { + this._registerToolCall(req.callId, 'scheduled'); + } + } + + // Fire and forget so we don't block the executor loop before waitForPendingTools can be called + void this.scheduler.schedule(updatedRequests, abortSignal); } async acceptAgentMessage(event: ServerGeminiStreamEvent): Promise { @@ -818,14 +880,22 @@ export class Task { if ( part.kind !== 'data' || !part.data || + // eslint-disable-next-line no-restricted-syntax typeof part.data['callId'] !== 'string' || + // eslint-disable-next-line no-restricted-syntax typeof part.data['outcome'] !== 'string' ) { return false; } + if (!part.data['outcome']) { + return false; + } const callId = part.data['callId']; const outcomeString = part.data['outcome']; + + this.toolsAlreadyConfirmed.add(callId); + let confirmationOutcome: ToolConfirmationOutcome | undefined; if (outcomeString === 'proceed_once') { @@ -838,6 +908,8 @@ export class Task { confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysServer; } else if (outcomeString === 'proceed_always_tool') { confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysTool; + } else if (outcomeString === 'proceed_always_and_save') { + confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysAndSave; } else if (outcomeString === 'modify_with_editor') { confirmationOutcome = ToolConfirmationOutcome.ModifyWithEditor; } else { @@ -848,8 +920,9 @@ export class Task { } const confirmationDetails = this.pendingToolConfirmationDetails.get(callId); + const correlationId = this.pendingCorrelationIds.get(callId); - if (!confirmationDetails) { + if (!confirmationDetails && !correlationId) { logger.warn( `[Task] Received tool confirmation for unknown or already processed callId: ${callId}`, ); @@ -871,24 +944,36 @@ export class Task { // This will trigger the scheduler to continue or cancel the specific tool. // The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled). - // If `edit` tool call, pass updated payload if presesent - if (confirmationDetails.type === 'edit') { - const newContent = part.data['newContent']; - const payload = - typeof newContent === 'string' - ? ({ newContent } as ToolConfirmationPayload) - : undefined; - this.skipFinalTrueAfterInlineEdit = !!payload; - try { + // If `edit` tool call, pass updated payload if present + const newContent = part.data['newContent']; + const payload = + confirmationDetails?.type === 'edit' && typeof newContent === 'string' + ? ({ newContent } as ToolConfirmationPayload) + : undefined; + this.skipFinalTrueAfterInlineEdit = !!payload; + + try { + if (correlationId) { + const loopContext: AgentLoopContext = this.config; + await loopContext.messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId, + confirmed: + confirmationOutcome !== ToolConfirmationOutcome.Cancel && + confirmationOutcome !== + ToolConfirmationOutcome.ModifyWithEditor, + outcome: confirmationOutcome, + payload, + }); + } else if (confirmationDetails?.onConfirm) { + // Fallback for legacy callback-based confirmation await confirmationDetails.onConfirm(confirmationOutcome, payload); - } finally { - // Once confirmationDetails.onConfirm finishes (or fails) with a payload, - // reset skipFinalTrueAfterInlineEdit so that external callers receive - // their call has been completed. - this.skipFinalTrueAfterInlineEdit = false; } - } else { - await confirmationDetails.onConfirm(confirmationOutcome); + } finally { + // Once confirmation payload is sent or callback finishes, + // reset skipFinalTrueAfterInlineEdit so that external callers receive + // their call has been completed. + this.skipFinalTrueAfterInlineEdit = false; } } finally { if (gcpProject) { @@ -904,6 +989,7 @@ export class Task { // Note !== ToolConfirmationOutcome.ModifyWithEditor does not work! if (confirmationOutcome !== 'modify_with_editor') { this.pendingToolConfirmationDetails.delete(callId); + this.pendingCorrelationIds.delete(callId); } // If outcome is Cancel, scheduler should update status to 'cancelled', which then resolves the tool. @@ -937,6 +1023,9 @@ export class Task { getAndClearCompletedTools(): CompletedToolCall[] { const tools = [...this.completedToolCalls]; + for (const tool of tools) { + this.processedToolCallIds.add(tool.request.callId); + } this.completedToolCalls = []; return tools; } @@ -997,6 +1086,7 @@ export class Task { }; // Set task state to working as we are about to call LLM this.setTaskStateAndPublishUpdate('working', stateChange); + this.currentAgentMessageId = uuidv4(); yield* this.geminiClient.sendMessageStream( llmParts, aborted, @@ -1018,6 +1108,10 @@ export class Task { if (confirmationHandled) { anyConfirmationHandled = true; // If a confirmation was handled, the scheduler will now run the tool (or cancel it). + // We resolve the toolCompletionPromise manually in checkInputRequiredState + // to break the original execution loop, so we must reset it here so the + // new loop correctly awaits the tool's final execution. + this._resetToolCompletionPromise(); // We don't send anything to the LLM for this part. // The subsequent tool execution will eventually lead to resolveToolCall. continue; @@ -1032,6 +1126,7 @@ export class Task { if (hasContentForLlm) { this.currentPromptId = this.config.getSessionId() + '########' + this.promptCount++; + this.currentAgentMessageId = uuidv4(); logger.info('[Task] Sending new parts to LLM.'); const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, @@ -1077,7 +1172,6 @@ export class Task { if (content === '') { return; } - logger.info('[Task] Sending text content to event bus.'); const message = this._createTextMessage(content); const textContent: TextContent = { kind: CoderAgentEvent.TextContentEvent, @@ -1109,7 +1203,7 @@ export class Task { data: content, } as Part, ], - messageId: uuidv4(), + messageId: this.currentAgentMessageId, taskId: this.id, contextId: this.contextId, }; diff --git a/packages/a2a-server/src/commands/init.test.ts b/packages/a2a-server/src/commands/init.test.ts index df2a213cba..b7020e0729 100644 --- a/packages/a2a-server/src/commands/init.test.ts +++ b/packages/a2a-server/src/commands/init.test.ts @@ -6,7 +6,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { InitCommand } from './init.js'; -import { performInit } from '@google/gemini-cli-core'; +import { + performInit, + type CommandActionReturn, + type Config, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { CoderAgentExecutor } from '../agent/executor.js'; @@ -14,7 +18,6 @@ import { CoderAgentEvent } from '../types.js'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import { createMockConfig } from '../utils/testing_utils.js'; import type { CommandContext } from './types.js'; -import type { CommandActionReturn, Config } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { diff --git a/packages/a2a-server/src/commands/memory.test.ts b/packages/a2a-server/src/commands/memory.test.ts index 40c5d1b90b..de5a09fcb2 100644 --- a/packages/a2a-server/src/commands/memory.test.ts +++ b/packages/a2a-server/src/commands/memory.test.ts @@ -9,6 +9,9 @@ import { listMemoryFiles, refreshMemory, showMemory, + type AnyDeclarativeTool, + type Config, + type ToolRegistry, } from '@google/gemini-cli-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -19,11 +22,6 @@ import { ShowMemoryCommand, } from './memory.js'; import type { CommandContext } from './types.js'; -import type { - AnyDeclarativeTool, - Config, - ToolRegistry, -} from '@google/gemini-cli-core'; // Mock the core functions vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -61,6 +59,9 @@ describe('a2a-server memory commands', () => { } as unknown as ToolRegistry; mockConfig = { + get toolRegistry() { + return mockToolRegistry; + }, getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), } as unknown as Config; @@ -170,17 +171,19 @@ describe('a2a-server memory commands', () => { ]); expect(mockAddMemory).toHaveBeenCalledWith(fact); - expect(mockConfig.getToolRegistry).toHaveBeenCalled(); expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory'); expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith( { fact }, expect.any(AbortSignal), undefined, { - sanitizationConfig: { - allowedEnvironmentVariables: [], - blockedEnvironmentVariables: [], - enableEnvironmentVariableRedaction: false, + shellExecutionConfig: { + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + sandboxManager: undefined, }, }, ); diff --git a/packages/a2a-server/src/commands/memory.ts b/packages/a2a-server/src/commands/memory.ts index 16af1d3fe2..f84d57b3fc 100644 --- a/packages/a2a-server/src/commands/memory.ts +++ b/packages/a2a-server/src/commands/memory.ts @@ -15,6 +15,7 @@ import type { CommandContext, CommandExecutionResponse, } from './types.js'; +import type { AgentLoopContext } from '@google/gemini-cli-core'; const DEFAULT_SANITIZATION_CONFIG = { allowedEnvironmentVariables: [], @@ -95,13 +96,17 @@ export class AddMemoryCommand implements Command { return { name: this.name, data: result.content }; } - const toolRegistry = context.config.getToolRegistry(); + const loopContext: AgentLoopContext = context.config; + const toolRegistry = loopContext.toolRegistry; const tool = toolRegistry.getTool(result.toolName); if (tool) { const abortController = new AbortController(); const signal = abortController.signal; await tool.buildAndExecute(result.toolArgs, signal, undefined, { - sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + shellExecutionConfig: { + sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + sandboxManager: loopContext.sandboxManager, + }, }); await refreshMemory(context.config); return { diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index e68ebc4431..cfe77311ea 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -16,6 +16,11 @@ import { ExperimentFlags, fetchAdminControlsOnce, type FetchAdminControlsResponse, + AuthType, + isHeadlessMode, + FatalAuthenticationError, + PolicyDecision, + PRIORITY_YOLO_ALLOW_ALL, } from '@google/gemini-cli-core'; // Mock dependencies @@ -28,6 +33,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { const mockConfig = { ...params, initialize: vi.fn(), + waitForMcpInit: vi.fn(), refreshAuth: vi.fn(), getExperiments: vi.fn().mockReturnValue({ flags: { @@ -49,6 +55,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { startupProfiler: { flush: vi.fn(), }, + isHeadlessMode: vi.fn().mockReturnValue(false), FileDiscoveryService: vi.fn(), getCodeAssistServer: vi.fn(), fetchAdminControlsOnce: vi.fn(), @@ -61,6 +68,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('../utils/logger.js', () => ({ logger: { info: vi.fn(), + warn: vi.fn(), error: vi.fn(), }, })); @@ -72,12 +80,11 @@ describe('loadConfig', () => { beforeEach(() => { vi.clearAllMocks(); - process.env['GEMINI_API_KEY'] = 'test-key'; + vi.stubEnv('GEMINI_API_KEY', 'test-key'); }); afterEach(() => { - delete process.env['CUSTOM_IGNORE_FILE_PATHS']; - delete process.env['GEMINI_API_KEY']; + vi.unstubAllEnvs(); }); describe('admin settings overrides', () => { @@ -86,6 +93,15 @@ describe('loadConfig', () => { expect(fetchAdminControlsOnce).not.toHaveBeenCalled(); }); + it('should pass clientName as a2a-server to Config', async () => { + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + clientName: 'a2a-server', + }), + ); + }); + describe('when admin controls experiment is enabled', () => { beforeEach(() => { // We need to cast to any here to modify the mock implementation @@ -94,6 +110,7 @@ describe('loadConfig', () => { const mockConfig = { ...(params as object), initialize: vi.fn(), + waitForMcpInit: vi.fn(), refreshAuth: vi.fn(), getExperiments: vi.fn().mockReturnValue({ flags: { @@ -197,7 +214,7 @@ describe('loadConfig', () => { it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => { const testPath = '/tmp/ignore'; - process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath; + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath); const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ @@ -222,7 +239,7 @@ describe('loadConfig', () => { it('should merge customIgnoreFilePaths from settings and env var', async () => { const envPath = '/env/ignore'; const settingsPath = '/settings/ignore'; - process.env['CUSTOM_IGNORE_FILE_PATHS'] = envPath; + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath); const settings: Settings = { fileFiltering: { customIgnoreFilePaths: [settingsPath], @@ -238,7 +255,7 @@ describe('loadConfig', () => { it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => { const paths = ['/path/one', '/path/two']; - process.env['CUSTOM_IGNORE_FILE_PATHS'] = paths.join(path.delimiter); + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter)); const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths); @@ -252,7 +269,7 @@ describe('loadConfig', () => { it('should initialize FileDiscoveryService with correct options', async () => { const testPath = '/tmp/ignore'; - process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath; + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath); const settings: Settings = { fileFiltering: { respectGitIgnore: false, @@ -309,5 +326,277 @@ describe('loadConfig', () => { }), ); }); + + it('should pass enableAgents to Config constructor', async () => { + const settings: Settings = { + experimental: { + enableAgents: false, + }, + }; + await loadConfig(settings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + enableAgents: false, + }), + ); + }); + + it('should default enableAgents to true when not provided', async () => { + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + enableAgents: true, + }), + ); + }); + + describe('interactivity', () => { + it('should set interactive true when not headless', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + interactive: true, + enableInteractiveShell: true, + }), + ); + }); + + it('should set interactive false when headless', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(true); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + interactive: false, + enableInteractiveShell: false, + }), + ); + }); + }); + + describe('YOLO mode', () => { + it('should enable YOLO mode and add policy rule when GEMINI_YOLO_MODE is true', async () => { + vi.stubEnv('GEMINI_YOLO_MODE', 'true'); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + approvalMode: 'yolo', + policyEngineConfig: expect.objectContaining({ + rules: expect.arrayContaining([ + expect.objectContaining({ + decision: PolicyDecision.ALLOW, + priority: PRIORITY_YOLO_ALLOW_ALL, + modes: ['yolo'], + allowRedirection: true, + }), + ]), + }), + }), + ); + }); + + it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => { + vi.stubEnv('GEMINI_YOLO_MODE', 'false'); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + approvalMode: 'default', + policyEngineConfig: expect.objectContaining({ + rules: [], + }), + }), + ); + }); + }); + + describe('authentication fallback', () => { + beforeEach(() => { + vi.stubEnv('USE_CCPA', 'true'); + vi.stubEnv('GEMINI_API_KEY', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => { + vi.stubEnv('CLOUD_SHELL', 'true'); + vi.mocked(isHeadlessMode).mockReturnValue(false); + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + throw new FatalAuthenticationError('Non-interactive session'); + } + return Promise.resolve(); + }); + + // Update the mock implementation for this test + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(refreshAuthMock).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + }); + + it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + throw new FatalAuthenticationError('Non-interactive session'); + } + return Promise.resolve(); + }); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await expect( + loadConfig(mockSettings, mockExtensionLoader, taskId), + ).rejects.toThrow('Non-interactive session'); + + expect(refreshAuthMock).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + }); + + it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => { + vi.stubEnv('CLOUD_SHELL', 'true'); + vi.mocked(isHeadlessMode).mockReturnValue(true); + + const refreshAuthMock = vi.fn().mockResolvedValue(undefined); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(refreshAuthMock).not.toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + }); + + it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => { + vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true'); + vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless + + const refreshAuthMock = vi.fn().mockResolvedValue(undefined); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(refreshAuthMock).not.toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + }); + + it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(true); + + const refreshAuthMock = vi.fn().mockResolvedValue(undefined); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await expect( + loadConfig(mockSettings, mockExtensionLoader, taskId), + ).rejects.toThrow( + 'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.', + ); + + expect(refreshAuthMock).not.toHaveBeenCalled(); + }); + + it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => { + vi.stubEnv('CLOUD_SHELL', 'true'); + vi.mocked(isHeadlessMode).mockReturnValue(false); + + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + throw new FatalAuthenticationError('OAuth failed'); + } + if (authType === AuthType.COMPUTE_ADC) { + throw new Error('ADC failed'); + } + return Promise.resolve(); + }); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await expect( + loadConfig(mockSettings, mockExtensionLoader, taskId), + ).rejects.toThrow( + 'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed', + ); + }); + }); }); }); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 6a27bca4d5..9474c4d9c5 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -8,11 +8,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as dotenv from 'dotenv'; -import type { - TelemetryTarget, - ConfigParameters, - ExtensionLoader, -} from '@google/gemini-cli-core'; import { AuthType, Config, @@ -28,6 +23,14 @@ import { fetchAdminControlsOnce, getCodeAssistServer, ExperimentFlags, + isHeadlessMode, + FatalAuthenticationError, + isCloudShell, + PolicyDecision, + PRIORITY_YOLO_ALLOW_ALL, + type TelemetryTarget, + type ConfigParameters, + type ExtensionLoader, } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; @@ -59,8 +62,14 @@ export async function loadConfig( } } + const approvalMode = + process.env['GEMINI_YOLO_MODE'] === 'true' + ? ApprovalMode.YOLO + : ApprovalMode.DEFAULT; + const configParams: ConfigParameters = { sessionId: taskId, + clientName: 'a2a-server', model: PREVIEW_GEMINI_MODEL, embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: undefined, // Sandbox might not be relevant for a server-side agent @@ -72,10 +81,20 @@ export async function loadConfig( excludeTools: settings.excludeTools || settings.tools?.exclude || undefined, allowedTools: settings.allowedTools || settings.tools?.allowed || undefined, showMemoryUsage: settings.showMemoryUsage || false, - approvalMode: - process.env['GEMINI_YOLO_MODE'] === 'true' - ? ApprovalMode.YOLO - : ApprovalMode.DEFAULT, + approvalMode, + policyEngineConfig: { + rules: + approvalMode === ApprovalMode.YOLO + ? [ + { + decision: PolicyDecision.ALLOW, + priority: PRIORITY_YOLO_ALLOW_ALL, + modes: [ApprovalMode.YOLO], + allowRedirection: true, + }, + ] + : [], + }, mcpServers: settings.mcpServers, cwd: workspaceDir, telemetry: { @@ -105,9 +124,10 @@ export async function loadConfig( trustedFolder: true, extensionLoader, checkpointing, - interactive: true, - enableInteractiveShell: true, + interactive: !isHeadlessMode(), + enableInteractiveShell: !isHeadlessMode(), ptyInfo: 'auto', + enableAgents: settings.experimental?.enableAgents ?? true, }; const fileService = new FileDiscoveryService(workspaceDir, { @@ -119,7 +139,6 @@ export async function loadConfig( await loadServerHierarchicalMemory( workspaceDir, [workspaceDir], - false, fileService, extensionLoader, folderTrust, @@ -168,6 +187,8 @@ export async function loadConfig( // Needed to initialize ToolRegistry, and git checkpointing if enabled await config.initialize(); + + await config.waitForMcpInit(); startupProfiler.flush(config); await refreshAuthentication(config, adcFilePath, 'Config'); @@ -255,7 +276,61 @@ async function refreshAuthentication( `[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`, ); } - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); + + const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'; + const isHeadless = isHeadlessMode(); + const shouldSkipOauth = isHeadless || useComputeAdc; + + if (shouldSkipOauth) { + if (isCloudShell() || useComputeAdc) { + logger.info( + `[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`, + ); + try { + await config.refreshAuth(AuthType.COMPUTE_ADC); + logger.info(`[${logPrefix}] COMPUTE_ADC successful.`); + } catch (adcError) { + const adcMessage = + adcError instanceof Error ? adcError.message : String(adcError); + throw new FatalAuthenticationError( + `COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`, + ); + } + } else { + throw new FatalAuthenticationError( + `Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`, + ); + } + } else { + try { + await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); + } catch (e) { + if ( + e instanceof FatalAuthenticationError && + (isCloudShell() || useComputeAdc) + ) { + logger.warn( + `[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`, + ); + try { + await config.refreshAuth(AuthType.COMPUTE_ADC); + logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`); + } catch (adcError) { + logger.error( + `[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`, + ); + const originalMessage = e instanceof Error ? e.message : String(e); + const adcMessage = + adcError instanceof Error ? adcError.message : String(adcError); + throw new FatalAuthenticationError( + `${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`, + ); + } + } else { + throw e; + } + } + } logger.info( `[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`, ); diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts index 7c51950535..ab80bced24 100644 --- a/packages/a2a-server/src/config/settings.test.ts +++ b/packages/a2a-server/src/config/settings.test.ts @@ -112,6 +112,18 @@ describe('loadSettings', () => { expect(result.fileFiltering?.respectGitIgnore).toBe(true); }); + it('should load experimental settings correctly', () => { + const settings = { + experimental: { + enableAgents: true, + }, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); + + const result = loadSettings(mockWorkspaceDir); + expect(result.experimental?.enableAgents).toBe(true); + }); + it('should overwrite top-level settings from workspace (shallow merge)', () => { const userSettings = { showMemoryUsage: false, diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index b3c44cc177..ced11a4daa 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -37,6 +37,9 @@ export interface Settings { showMemoryUsage?: boolean; checkpointing?: CheckpointingSettings; folderTrust?: boolean; + general?: { + previewFeatures?: boolean; + }; // Git-aware file filtering settings fileFiltering?: { @@ -45,6 +48,9 @@ export interface Settings { enableRecursiveFileSearch?: boolean; customIgnoreFilePaths?: string[]; }; + experimental?: { + enableAgents?: boolean; + }; } export interface SettingsError { diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index c863fb1472..4a883992b5 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Config, - ToolCallConfirmationDetails, +import { + GeminiEventType, + ApprovalMode, + type Config, + type ToolCallConfirmationDetails, } from '@google/gemini-cli-core'; -import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core'; import type { TaskStatusUpdateEvent, SendStreamingMessageSuccessResponse, @@ -64,7 +65,12 @@ vi.mock('../utils/logger.js', () => ({ })); let config: Config; -const getToolRegistrySpy = vi.fn().mockReturnValue(ApprovalMode.DEFAULT); +const getToolRegistrySpy = vi.fn().mockReturnValue({ + getTool: vi.fn(), + getAllToolNames: vi.fn().mockReturnValue([]), + getAllTools: vi.fn().mockReturnValue([]), + getToolsByServer: vi.fn().mockReturnValue([]), +}); const getApprovalModeSpy = vi.fn(); const getShellExecutionConfigSpy = vi.fn(); const getExtensionsSpy = vi.fn(); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 161139279b..35ca48949f 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import express from 'express'; +import express, { type Request } from 'express'; import type { AgentCard, Message } from '@a2a-js/sdk'; import { @@ -13,8 +13,9 @@ import { InMemoryTaskStore, DefaultExecutionEventBus, type AgentExecutionEvent, + UnauthenticatedUser, } from '@a2a-js/sdk/server'; -import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components +import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import type { AgentSettings } from '../types.js'; @@ -55,8 +56,17 @@ const coderAgentCard: AgentCard = { pushNotifications: false, stateTransitionHistory: true, }, - securitySchemes: undefined, - security: undefined, + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + basicAuth: { + type: 'http', + scheme: 'basic', + }, + }, + security: [{ bearerAuth: [] }, { basicAuth: [] }], defaultInputModes: ['text'], defaultOutputModes: ['text'], skills: [ @@ -81,6 +91,35 @@ export function updateCoderAgentCardUrl(port: number) { coderAgentCard.url = `http://localhost:${port}/`; } +const customUserBuilder: UserBuilder = async (req: Request) => { + const auth = req.headers['authorization']; + if (auth) { + const scheme = auth.split(' ')[0]; + logger.info( + `[customUserBuilder] Received Authorization header with scheme: ${scheme}`, + ); + } + if (!auth) return new UnauthenticatedUser(); + + // 1. Bearer Auth + if (auth.startsWith('Bearer ')) { + const token = auth.substring(7); + if (token === 'valid-token') { + return { userName: 'bearer-user', isAuthenticated: true }; + } + } + + // 2. Basic Auth + if (auth.startsWith('Basic ')) { + const credentials = Buffer.from(auth.substring(6), 'base64').toString(); + if (credentials === 'admin:password') { + return { userName: 'basic-user', isAuthenticated: true }; + } + } + + return new UnauthenticatedUser(); +}; + async function handleExecuteCommand( req: express.Request, res: express.Response, @@ -204,7 +243,7 @@ export async function createApp() { requestStorage.run({ req }, next); }); - const appBuilder = new A2AExpressApp(requestHandler); + const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder); expressApp = appBuilder.setupRoutes(expressApp, ''); expressApp.use(express.json()); diff --git a/packages/a2a-server/src/persistence/gcs.test.ts b/packages/a2a-server/src/persistence/gcs.test.ts index 43563448e5..353a8312d5 100644 --- a/packages/a2a-server/src/persistence/gcs.test.ts +++ b/packages/a2a-server/src/persistence/gcs.test.ts @@ -11,8 +11,16 @@ import { gzipSync, gunzipSync } from 'node:zlib'; import { v4 as uuidv4 } from 'uuid'; import type { Task as SDKTask } from '@a2a-js/sdk'; import type { TaskStore } from '@a2a-js/sdk/server'; -import type { Mocked, MockedClass, Mock } from 'vitest'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + describe, + it, + expect, + beforeEach, + vi, + type Mocked, + type MockedClass, + type Mock, +} from 'vitest'; import { GCSTaskStore, NoOpTaskStore } from './gcs.js'; import { logger } from '../utils/logger.js'; diff --git a/packages/a2a-server/src/utils/executor_utils.ts b/packages/a2a-server/src/utils/executor_utils.ts index b595a6905b..cf635f8822 100644 --- a/packages/a2a-server/src/utils/executor_utils.ts +++ b/packages/a2a-server/src/utils/executor_utils.ts @@ -8,8 +8,7 @@ import type { Message } from '@a2a-js/sdk'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import { v4 as uuidv4 } from 'uuid'; -import { CoderAgentEvent } from '../types.js'; -import type { StateChange } from '../types.js'; +import { CoderAgentEvent, type StateChange } from '../types.js'; export async function pushTaskStateFailed( error: unknown, diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 9cb0657c7a..fd4d721732 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -16,11 +16,16 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, GeminiClient, HookSystem, + type MessageBus, PolicyDecision, tmpdir, + type Config, + type Storage, + NoopSandboxManager, + type ToolRegistry, + type SandboxManager, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; -import type { Config, Storage } from '@google/gemini-cli-core'; import { expect, vi } from 'vitest'; export function createMockConfig( @@ -29,6 +34,28 @@ export function createMockConfig( const tmpDir = tmpdir(); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mockConfig = { + get config() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return this as unknown as Config; + }, + get toolRegistry() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const config = this as unknown as Config; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return config.getToolRegistry?.() as unknown as ToolRegistry; + }, + get messageBus() { + return ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (this as unknown as Config).getMessageBus?.() as unknown as MessageBus + ); + }, + get geminiClient() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const config = this as unknown as Config; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return config.getGeminiClient?.() as unknown as GeminiClient; + }, getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn(), getAllToolNames: vi.fn().mockReturnValue([]), @@ -72,8 +99,22 @@ export function createMockConfig( }), getGitService: vi.fn(), validatePathAccess: vi.fn().mockReturnValue(undefined), + getShellExecutionConfig: vi.fn().mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + sandboxManager: new NoopSandboxManager() as unknown as SandboxManager, + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), ...overrides, } as unknown as Config; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (mockConfig as unknown as { config: Config; promptId: string }).promptId = + 'test-prompt-id'; + mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus()); mockConfig.getHookSystem = vi .fn() diff --git a/packages/cli/GEMINI.md b/packages/cli/GEMINI.md index 8ab50f6b57..e98ca81376 100644 --- a/packages/cli/GEMINI.md +++ b/packages/cli/GEMINI.md @@ -5,7 +5,7 @@ - Always fix react-hooks/exhaustive-deps lint errors by adding the missing dependencies. - **Shortcuts**: only define keyboard shortcuts in - `packages/cli/src/config/keyBindings.ts` + `packages/cli/src/ui/key/keyBindings.ts` - Do not implement any logic performing custom string measurement or string truncation. Use Ink layout instead leveraging ResizeObserver as needed. - Avoid prop drilling when at all possible. @@ -15,4 +15,11 @@ - **Utilities**: Use `renderWithProviders` and `waitFor` from `packages/cli/src/test-utils/`. - **Snapshots**: Use `toMatchSnapshot()` to verify Ink output. +- **SVG Snapshots**: Use `await expect(renderResult).toMatchSvgSnapshot()` for + UI components whenever colors or detailed visual layout matter. SVG snapshots + capture styling accurately. Make sure to await the `waitUntilReady()` of the + render result before asserting. After updating SVG snapshots, always examine + the resulting `.svg` files (e.g. by reading their content or visually + inspecting them) to ensure the render and colors actually look as expected and + don't just contain an error message. - **Mocks**: Use mocks as sparingly as possible. diff --git a/packages/cli/package.json b/packages/cli/package.json index 5ad1e37d9d..40acd6cf88 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.36.0-nightly.20260317.2f90b4653", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -20,25 +20,26 @@ "format": "prettier --write .", "test": "vitest run", "test:ci": "vitest run", + "posttest": "npm run build", "typecheck": "tsc --noEmit" }, "files": [ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@google/gemini-cli-core": "file:../core", - "@google/genai": "1.41.0", + "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.23.0", "ansi-escapes": "^7.3.0", "ansi-regex": "^6.2.2", "chalk": "^4.1.2", "cli-spinners": "^2.9.2", - "clipboardy": "^5.0.0", + "clipboardy": "~5.2.0", "color-convert": "^2.0.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", diff --git a/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap index 8c1a85cdd7..92f396a59c 100644 --- a/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap +++ b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap @@ -4,7 +4,7 @@ exports[`runNonInteractive > should emit appropriate error event in streaming JS "{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} {"type":"message","timestamp":"","role":"user","content":"Loop test"} {"type":"error","timestamp":"","severity":"warning","message":"Loop detected, stopping execution"} -{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0}} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} " `; @@ -12,7 +12,7 @@ exports[`runNonInteractive > should emit appropriate error event in streaming JS "{"type":"init","timestamp":"","session_id":"test-session-id","model":"test-model"} {"type":"message","timestamp":"","role":"user","content":"Max turns test"} {"type":"error","timestamp":"","severity":"error","message":"Maximum session turns exceeded"} -{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0}} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} " `; @@ -23,7 +23,7 @@ exports[`runNonInteractive > should emit appropriate events for streaming JSON o {"type":"tool_use","timestamp":"","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}} {"type":"tool_result","timestamp":"","tool_id":"tool-1","status":"success","output":"Tool executed successfully"} {"type":"message","timestamp":"","role":"assistant","content":"Final answer","delta":true} -{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0}} +{"type":"result","timestamp":"","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":,"tool_calls":0,"models":{}}} " `; diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/acp/acpClient.test.ts similarity index 62% rename from packages/cli/src/zed-integration/zedIntegration.test.ts rename to packages/cli/src/acp/acpClient.test.ts index 37da3035c3..0f9c4a8e5b 100644 --- a/packages/cli/src/zed-integration/zedIntegration.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -14,7 +14,8 @@ import { type Mock, type Mocked, } from 'vitest'; -import { GeminiAgent, Session } from './zedIntegration.js'; +import { GeminiAgent, Session } from './acpClient.js'; +import type { CommandHandler } from './commandHandler.js'; import * as acp from '@agentclientprotocol/sdk'; import { AuthType, @@ -26,6 +27,7 @@ import { type Config, type MessageBus, LlmRole, + type GitService, } from '@google/gemini-cli-core'; import { SettingScope, @@ -62,7 +64,33 @@ vi.mock('node:path', async (importOriginal) => { }; }); -// Mock ReadManyFilesTool +vi.mock('../ui/commands/memoryCommand.js', () => ({ + memoryCommand: { + name: 'memory', + action: vi.fn(), + }, +})); + +vi.mock('../ui/commands/extensionsCommand.js', () => ({ + extensionsCommand: vi.fn().mockReturnValue({ + name: 'extensions', + action: vi.fn(), + }), +})); + +vi.mock('../ui/commands/restoreCommand.js', () => ({ + restoreCommand: vi.fn().mockReturnValue({ + name: 'restore', + action: vi.fn(), + }), +})); + +vi.mock('../ui/commands/initCommand.js', () => ({ + initCommand: { + name: 'init', + action: vi.fn(), + }, +})); vi.mock( '@google/gemini-cli-core', async ( @@ -129,6 +157,7 @@ describe('GeminiAgent', () => { mockConfig = { refreshAuth: vi.fn(), initialize: vi.fn(), + waitForMcpInit: vi.fn(), getFileSystemService: vi.fn(), setFileSystemService: vi.fn(), getContentGeneratorConfig: vi.fn(), @@ -143,7 +172,14 @@ describe('GeminiAgent', () => { unsubscribe: vi.fn(), }), getApprovalMode: vi.fn().mockReturnValue('default'), - isPlanEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), + getGemini31LaunchedSync: vi.fn().mockReturnValue(false), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), + getCheckpointingEnabled: vi.fn().mockReturnValue(false), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + get config() { + return this; + }, } as unknown as Mocked>>; mockSettings = { merged: { @@ -176,7 +212,16 @@ describe('GeminiAgent', () => { }); expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION); - expect(response.authMethods).toHaveLength(3); + expect(response.authMethods).toHaveLength(4); + const gatewayAuth = response.authMethods?.find( + (m) => m.id === AuthType.GATEWAY, + ); + expect(gatewayAuth?._meta).toEqual({ + gateway: { + protocol: 'google', + restartRequired: 'false', + }, + }); const geminiAuth = response.authMethods?.find( (m) => m.id === AuthType.USE_GEMINI, ); @@ -196,6 +241,8 @@ describe('GeminiAgent', () => { expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, undefined, + undefined, + undefined, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -215,6 +262,8 @@ describe('GeminiAgent', () => { expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.USE_GEMINI, 'test-api-key', + undefined, + undefined, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -223,7 +272,47 @@ describe('GeminiAgent', () => { ); }); + it('should authenticate correctly with gateway method', async () => { + await agent.authenticate({ + methodId: AuthType.GATEWAY, + _meta: { + gateway: { + baseUrl: 'https://example.com', + headers: { Authorization: 'Bearer token' }, + }, + }, + } as unknown as acp.AuthenticateRequest); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.GATEWAY, + undefined, + 'https://example.com', + { Authorization: 'Bearer token' }, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.GATEWAY, + ); + }); + + it('should throw acp.RequestError when gateway payload is malformed', async () => { + await expect( + agent.authenticate({ + methodId: AuthType.GATEWAY, + _meta: { + gateway: { + // Invalid baseUrl + baseUrl: 123, + headers: { Authorization: 'Bearer token' }, + }, + }, + } as unknown as acp.AuthenticateRequest), + ).rejects.toThrow(/Malformed gateway payload/); + }); + it('should create a new session', async () => { + vi.useFakeTimers(); mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({ apiKey: 'test-key', }); @@ -236,6 +325,17 @@ describe('GeminiAgent', () => { expect(loadCliConfig).toHaveBeenCalled(); expect(mockConfig.initialize).toHaveBeenCalled(); expect(mockConfig.getGeminiClient).toHaveBeenCalled(); + + // Verify deferred call + await vi.runAllTimersAsync(); + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + }), + }), + ); + vi.useRealTimers(); }); it('should return modes without plan mode when plan is disabled', async () => { @@ -262,6 +362,38 @@ describe('GeminiAgent', () => { ], currentModeId: 'default', }); + expect(response.models).toEqual({ + availableModels: expect.arrayContaining([ + expect.objectContaining({ + modelId: 'auto-gemini-2.5', + name: 'Auto (Gemini 2.5)', + }), + ]), + currentModelId: 'gemini-pro', + }); + }); + + it('should include preview models when user has access', async () => { + mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true); + mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true); + + const response = await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + }); + + expect(response.models?.availableModels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + modelId: 'auto-gemini-3', + name: expect.stringContaining('Auto'), + }), + expect.objectContaining({ + modelId: 'gemini-3.1-pro-preview', + name: 'gemini-3.1-pro-preview', + }), + ]), + ); }); it('should return modes with plan mode when plan is enabled', async () => { @@ -289,6 +421,15 @@ describe('GeminiAgent', () => { ], currentModeId: 'plan', }); + expect(response.models).toEqual({ + availableModels: expect.arrayContaining([ + expect.objectContaining({ + modelId: 'auto-gemini-2.5', + name: 'Auto (Gemini 2.5)', + }), + ]), + currentModelId: 'gemini-pro', + }); }); it('should fail session creation if Gemini API key is missing', async () => { @@ -410,7 +551,7 @@ describe('GeminiAgent', () => { }); expect(session.prompt).toHaveBeenCalled(); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should delegate setMode to session', async () => { @@ -438,6 +579,32 @@ describe('GeminiAgent', () => { }), ).rejects.toThrow('Session not found: unknown'); }); + + it('should delegate setModel to session (unstable)', async () => { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const session = ( + agent as unknown as { sessions: Map } + ).sessions.get('test-session-id'); + if (!session) throw new Error('Session not found'); + session.setModel = vi.fn().mockReturnValue({}); + + const result = await agent.unstable_setSessionModel({ + sessionId: 'test-session-id', + modelId: 'gemini-2.0-pro-exp', + }); + + expect(session.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp'); + expect(result).toEqual({}); + }); + + it('should throw error when setting model on non-existent session (unstable)', async () => { + await expect( + agent.unstable_setSessionModel({ + sessionId: 'unknown', + modelId: 'gemini-2.0-pro-exp', + }), + ).rejects.toThrow('Session not found: unknown'); + }); }); describe('Session', () => { @@ -476,6 +643,7 @@ describe('Session', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + getMcpServers: vi.fn(), getFileService: vi.fn().mockReturnValue({ shouldIgnoreFile: vi.fn().mockReturnValue(false), }), @@ -485,7 +653,18 @@ describe('Session', () => { getDebugMode: vi.fn().mockReturnValue(false), getMessageBus: vi.fn().mockReturnValue(mockMessageBus), setApprovalMode: vi.fn(), - isPlanEnabled: vi.fn().mockReturnValue(false), + setModel: vi.fn(), + isPlanEnabled: vi.fn().mockReturnValue(true), + getCheckpointingEnabled: vi.fn().mockReturnValue(false), + getGitService: vi.fn().mockResolvedValue({} as GitService), + waitForMcpInit: vi.fn(), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + get config() { + return this; + }, + get toolRegistry() { + return mockToolRegistry; + }, } as unknown as Mocked; mockConnection = { sessionUpdate: vi.fn(), @@ -493,13 +672,60 @@ describe('Session', () => { sendNotification: vi.fn(), } as unknown as Mocked; - session = new Session('session-1', mockChat, mockConfig, mockConnection); + session = new Session('session-1', mockChat, mockConfig, mockConnection, { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + merged: { settings: {} }, + errors: [], + } as unknown as LoadedSettings); }); afterEach(() => { vi.clearAllMocks(); }); + it('should send available commands', async () => { + await session.sendAvailableCommands(); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + availableCommands: expect.arrayContaining([ + expect.objectContaining({ name: 'memory' }), + expect.objectContaining({ name: 'extensions' }), + expect.objectContaining({ name: 'restore' }), + expect.objectContaining({ name: 'init' }), + ]), + }), + }), + ); + }); + + it('should await MCP initialization before processing a prompt', async () => { + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [{ content: { parts: [{ text: 'Hi' }] } }] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'test' }], + }); + + expect(mockConfig.waitForMcpInit).toHaveBeenCalledOnce(); + const waitOrder = (mockConfig.waitForMcpInit as Mock).mock + .invocationCallOrder[0]; + const sendOrder = (mockChat.sendMessageStream as Mock).mock + .invocationCallOrder[0]; + expect(waitOrder).toBeLessThan(sendOrder); + }); + it('should handle prompt with text response', async () => { const stream = createMockStream([ { @@ -524,7 +750,114 @@ describe('Session', () => { content: { type: 'text', text: 'Hello' }, }, }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); + }); + + it('should handle /memory command', async () => { + const handleCommandSpy = vi + .spyOn( + (session as unknown as { commandHandler: CommandHandler }) + .commandHandler, + 'handleCommand', + ) + .mockResolvedValue(true); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '/memory view' }], + }); + + expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(handleCommandSpy).toHaveBeenCalledWith( + '/memory view', + expect.any(Object), + ); + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + }); + + it('should handle /extensions command', async () => { + const handleCommandSpy = vi + .spyOn( + (session as unknown as { commandHandler: CommandHandler }) + .commandHandler, + 'handleCommand', + ) + .mockResolvedValue(true); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '/extensions list' }], + }); + + expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(handleCommandSpy).toHaveBeenCalledWith( + '/extensions list', + expect.any(Object), + ); + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + }); + + it('should handle /extensions explore command', async () => { + const handleCommandSpy = vi + .spyOn( + (session as unknown as { commandHandler: CommandHandler }) + .commandHandler, + 'handleCommand', + ) + .mockResolvedValue(true); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '/extensions explore' }], + }); + + expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(handleCommandSpy).toHaveBeenCalledWith( + '/extensions explore', + expect.any(Object), + ); + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + }); + + it('should handle /restore command', async () => { + const handleCommandSpy = vi + .spyOn( + (session as unknown as { commandHandler: CommandHandler }) + .commandHandler, + 'handleCommand', + ) + .mockResolvedValue(true); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '/restore' }], + }); + + expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(handleCommandSpy).toHaveBeenCalledWith( + '/restore', + expect.any(Object), + ); + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + }); + + it('should handle /init command', async () => { + const handleCommandSpy = vi + .spyOn( + (session as unknown as { commandHandler: CommandHandler }) + .commandHandler, + 'handleCommand', + ) + .mockResolvedValue(true); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '/init' }], + }); + + expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object)); + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); it('should handle tool calls', async () => { @@ -570,10 +903,13 @@ describe('Session', () => { update: expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed', + title: 'Test Tool', + locations: [], + kind: 'read', }), }), ); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should handle tool call permission request', async () => { @@ -625,6 +961,188 @@ describe('Session', () => { ); }); + it('should exclude always allow options when disableAlwaysAllow is true', async () => { + mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(true); + const confirmationDetails = { + type: 'info', + onConfirm: vi.fn(), + }; + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: ToolConfirmationOutcome.ProceedOnce, + }, + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockConnection.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.not.arrayContaining([ + expect.objectContaining({ + optionId: ToolConfirmationOutcome.ProceedAlways, + }), + ]), + }), + ); + }); + + it('should use filePath for ACP diff content in permission request', async () => { + const confirmationDetails = { + type: 'edit', + title: 'Confirm Write: test.txt', + fileName: 'test.txt', + filePath: '/tmp/test.txt', + originalContent: 'old', + newContent: 'new', + onConfirm: vi.fn(), + }; + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: ToolConfirmationOutcome.ProceedOnce, + }, + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockConnection.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ + content: expect.arrayContaining([ + expect.objectContaining({ + type: 'diff', + path: '/tmp/test.txt', + oldText: 'old', + newText: 'new', + }), + ]), + }), + }), + ); + }); + + it('should use filePath for ACP diff content in tool result', async () => { + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(null), + execute: vi.fn().mockResolvedValue({ + llmContent: 'Tool Result', + returnDisplay: { + fileName: 'test.txt', + filePath: '/tmp/test.txt', + originalContent: 'old', + newContent: 'new', + }, + }), + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + const updateCalls = mockConnection.sessionUpdate.mock.calls.map( + (call) => call[0], + ); + const toolCallUpdate = updateCalls.find( + (call) => call.update?.sessionUpdate === 'tool_call_update', + ); + + expect(toolCallUpdate).toEqual( + expect.objectContaining({ + update: expect.objectContaining({ + content: expect.arrayContaining([ + expect.objectContaining({ + type: 'diff', + path: '/tmp/test.txt', + oldText: 'old', + newText: 'new', + }), + ]), + }), + }), + ); + }); + it('should handle tool call cancellation by user', async () => { const confirmationDetails = { type: 'info', @@ -800,6 +1318,18 @@ describe('Session', () => { expect(path.resolve).toHaveBeenCalled(); expect(fs.stat).toHaveBeenCalled(); + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + status: 'completed', + title: 'Read files', + locations: [], + kind: 'read', + }), + }), + ); + // Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content) // Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream expect(mockChat.sendMessageStream).toHaveBeenCalledWith( @@ -815,6 +1345,65 @@ describe('Session', () => { ); }); + it('should handle @path resolution error', async () => { + (path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt'); + (fs.stat as unknown as Mock).mockResolvedValue({ + isDirectory: () => false, + }); + (isWithinRoot as unknown as Mock).mockReturnValue(true); + + const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock; + MockReadManyFilesTool.mockImplementationOnce(() => ({ + name: 'read_many_files', + kind: 'read', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Read files', + toolLocations: () => [], + execute: vi.fn().mockRejectedValue(new Error('File read failed')), + }), + })); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await expect( + session.prompt({ + sessionId: 'session-1', + prompt: [ + { type: 'text', text: 'Read' }, + { + type: 'resource_link', + uri: 'file://error.txt', + mimeType: 'text/plain', + name: 'error.txt', + }, + ], + }), + ).rejects.toThrow('File read failed'); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + status: 'failed', + content: expect.arrayContaining([ + expect.objectContaining({ + content: expect.objectContaining({ + text: expect.stringMatching(/File read failed/), + }), + }), + ]), + kind: 'read', + }), + }), + ); + }); + it('should handle cancellation during prompt', async () => { let streamController: ReadableStreamDefaultController; const stream = new ReadableStream({ @@ -928,6 +1517,7 @@ describe('Session', () => { content: expect.objectContaining({ text: 'Tool failed' }), }), ]), + kind: 'read', }), }), ); @@ -1056,4 +1646,30 @@ describe('Session', () => { 'Invalid or unavailable mode: invalid-mode', ); }); + + it('should set model on config', () => { + session.setModel('gemini-2.0-flash-exp'); + expect(mockConfig.setModel).toHaveBeenCalledWith('gemini-2.0-flash-exp'); + }); + + it('should handle unquoted commands from autocomplete (with empty leading parts)', async () => { + // Mock handleCommand to verify it gets called + const handleCommandSpy = vi + .spyOn( + (session as unknown as { commandHandler: CommandHandler }) + .commandHandler, + 'handleCommand', + ) + .mockResolvedValue(true); + + await session.prompt({ + sessionId: 'session-1', + prompt: [ + { type: 'text', text: '' }, + { type: 'text', text: '/memory' }, + ], + }); + + expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything()); + }); }); diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/acp/acpClient.ts similarity index 73% rename from packages/cli/src/zed-integration/zedIntegration.ts rename to packages/cli/src/acp/acpClient.ts index e89a884ab5..5e3f3666b1 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -4,15 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Config, - GeminiChat, - ToolResult, - ToolCallConfirmationDetails, - FilterFilesOptions, - ConversationRecord, -} from '@google/gemini-cli-core'; import { + type Config, + type GeminiChat, + type ToolResult, + type ToolCallConfirmationDetails, + type FilterFilesOptions, + type ConversationRecord, CoreToolCallStatus, AuthType, logToolCall, @@ -39,6 +37,17 @@ import { ApprovalMode, getVersion, convertSessionToClientHistory, + DEFAULT_GEMINI_MODEL, + DEFAULT_GEMINI_FLASH_MODEL, + DEFAULT_GEMINI_FLASH_LITE_MODEL, + PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + PREVIEW_GEMINI_FLASH_MODEL, + DEFAULT_GEMINI_MODEL_AUTO, + PREVIEW_GEMINI_MODEL_AUTO, + getDisplayString, + type AgentLoopContext, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; import { AcpFileSystemService } from './fileSystemService.js'; @@ -49,23 +58,28 @@ function hasMeta(obj: unknown): obj is { _meta?: Record } { return typeof obj === 'object' && obj !== null && '_meta' in obj; } import type { Content, Part, FunctionCall } from '@google/genai'; -import type { LoadedSettings } from '../config/settings.js'; -import { SettingScope, loadSettings } from '../config/settings.js'; +import { + SettingScope, + loadSettings, + type LoadedSettings, +} from '../config/settings.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { z } from 'zod'; import { randomUUID } from 'node:crypto'; -import type { CliArgs } from '../config/config.js'; -import { loadCliConfig } from '../config/config.js'; +import { loadCliConfig, type CliArgs } from '../config/config.js'; import { runExitCleanup } from '../utils/cleanup.js'; import { SessionSelector } from '../utils/sessionUtils.js'; -export async function runZedIntegration( +import { CommandHandler } from './commandHandler.js'; +export async function runAcpClient( config: Config, settings: LoadedSettings, argv: CliArgs, ) { + // ... (skip unchanged lines) ... + const { stdout: workingStdout } = createWorkingStdio(); const stdout = Writable.toWeb(workingStdout) as WritableStream; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -87,9 +101,11 @@ export class GeminiAgent { private sessions: Map = new Map(); private clientCapabilities: acp.ClientCapabilities | undefined; private apiKey: string | undefined; + private baseUrl: string | undefined; + private customHeaders: Record | undefined; constructor( - private config: Config, + private context: AgentLoopContext, private settings: LoadedSettings, private argv: CliArgs, private connection: acp.AgentSideConnection, @@ -120,9 +136,20 @@ export class GeminiAgent { name: 'Vertex AI', description: 'Use an API key with Vertex AI GenAI API', }, + { + id: AuthType.GATEWAY, + name: 'AI API Gateway', + description: 'Use a custom AI API Gateway', + _meta: { + gateway: { + protocol: 'google', + restartRequired: 'false', + }, + }, + }, ]; - await this.config.initialize(); + await this.context.config.initialize(); const version = await getVersion(); return { protocolVersion: acp.PROTOCOL_VERSION, @@ -168,7 +195,38 @@ export class GeminiAgent { if (apiKey) { this.apiKey = apiKey; } - await this.config.refreshAuth(method, apiKey ?? this.apiKey); + + // Extract gateway details if present + const gatewaySchema = z.object({ + baseUrl: z.string().optional(), + headers: z.record(z.string()).optional(), + }); + + let baseUrl: string | undefined; + let headers: Record | undefined; + + if (meta?.['gateway']) { + const result = gatewaySchema.safeParse(meta['gateway']); + if (result.success) { + baseUrl = result.data.baseUrl; + headers = result.data.headers; + } else { + throw new acp.RequestError( + -32602, + `Malformed gateway payload: ${result.error.message}`, + ); + } + } + + this.baseUrl = baseUrl; + this.customHeaders = headers; + + await this.context.config.refreshAuth( + method, + apiKey ?? this.apiKey, + baseUrl, + headers, + ); } catch (e) { throw new acp.RequestError(-32000, getAcpErrorMessage(e)); } @@ -198,7 +256,12 @@ export class GeminiAgent { let isAuthenticated = false; let authErrorMessage = ''; try { - await config.refreshAuth(authType, this.apiKey); + await config.refreshAuth( + authType, + this.apiKey, + this.baseUrl, + this.customHeaders, + ); isAuthenticated = true; // Extra validation for Gemini API key @@ -240,16 +303,37 @@ export class GeminiAgent { const geminiClient = config.getGeminiClient(); const chat = await geminiClient.startChat(); - const session = new Session(sessionId, chat, config, this.connection); + const session = new Session( + sessionId, + chat, + config, + this.connection, + this.settings, + ); this.sessions.set(sessionId, session); - return { + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + session.sendAvailableCommands(); + }, 0); + + const { availableModels, currentModelId } = buildAvailableModels( + config, + loadedSettings, + ); + + const response = { sessionId, modes: { availableModes: buildAvailableModes(config.isPlanEnabled()), currentModeId: config.getApprovalMode(), }, + models: { + availableModels, + currentModelId, + }, }; + return response; } async loadSession({ @@ -291,6 +375,7 @@ export class GeminiAgent { geminiClient.getChat(), config, this.connection, + this.settings, ); this.sessions.set(sessionId, session); @@ -298,12 +383,27 @@ export class GeminiAgent { // eslint-disable-next-line @typescript-eslint/no-floating-promises session.streamHistory(sessionData.messages); - return { + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + session.sendAvailableCommands(); + }, 0); + + const { availableModels, currentModelId } = buildAvailableModels( + config, + this.settings, + ); + + const response = { modes: { availableModes: buildAvailableModes(config.isPlanEnabled()), currentModeId: config.getApprovalMode(), }, + models: { + availableModels, + currentModelId, + }, }; + return response; } private async initializeSessionConfig( @@ -323,7 +423,12 @@ export class GeminiAgent { // This satisfies the security requirement to verify the user before executing // potentially unsafe server definitions. try { - await config.refreshAuth(selectedAuthType, this.apiKey); + await config.refreshAuth( + selectedAuthType, + this.apiKey, + this.baseUrl, + this.customHeaders, + ); } catch (e) { debugLogger.error(`Authentication failed: ${e}`); throw acp.RequestError.authRequired(); @@ -414,16 +519,28 @@ export class GeminiAgent { } return session.setMode(params.modeId); } + + async unstable_setSessionModel( + params: acp.SetSessionModelRequest, + ): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + return session.setModel(params.modelId); + } } export class Session { private pendingPrompt: AbortController | null = null; + private commandHandler = new CommandHandler(); constructor( private readonly id: string, private readonly chat: GeminiChat, - private readonly config: Config, + private readonly context: AgentLoopContext, private readonly connection: acp.AgentSideConnection, + private readonly settings: LoadedSettings, ) {} async cancelPendingPrompt(): Promise { @@ -436,13 +553,36 @@ export class Session { } setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse { - const availableModes = buildAvailableModes(this.config.isPlanEnabled()); + const availableModes = buildAvailableModes( + this.context.config.isPlanEnabled(), + ); const mode = availableModes.find((m) => m.id === modeId); if (!mode) { throw new Error(`Invalid or unavailable mode: ${modeId}`); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - this.config.setApprovalMode(mode.id as ApprovalMode); + this.context.config.setApprovalMode(mode.id as ApprovalMode); + return {}; + } + + private getAvailableCommands() { + return this.commandHandler.getAvailableCommands(); + } + + async sendAvailableCommands(): Promise { + const availableCommands = this.getAvailableCommands().map((command) => ({ + name: command.name, + description: command.description, + })); + + await this.sendUpdate({ + sessionUpdate: 'available_commands_update', + availableCommands, + }); + } + + setModel(modelId: acp.ModelId): acp.SetSessionModelResponse { + this.context.config.setModel(modelId); return {}; } @@ -497,7 +637,7 @@ export class Session { } } - const tool = this.config.getToolRegistry().getTool(toolCall.name); + const tool = this.context.toolRegistry.getTool(toolCall.name); await this.sendUpdate({ sessionUpdate: 'tool_call', @@ -521,11 +661,60 @@ export class Session { const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; + await this.context.config.waitForMcpInit(); + const promptId = Math.random().toString(16).slice(2); const chat = this.chat; const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); + // Command interception + let commandText = ''; + + for (const part of parts) { + if (typeof part === 'object' && part !== null) { + if ('text' in part) { + // It is a text part + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion + const text = (part as any).text; + if (typeof text === 'string') { + commandText += text; + } + } else { + // Non-text part (image, embedded resource) + // Stop looking for command + break; + } + } + } + + commandText = commandText.trim(); + + if ( + commandText && + (commandText.startsWith('/') || commandText.startsWith('$')) + ) { + // If we found a command, pass it to handleCommand + // Note: handleCommand currently expects `commandText` to be the command string + // It uses `parts` argument but effectively ignores it in current implementation + const handled = await this.handleCommand(commandText, parts); + if (handled) { + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { input_tokens: 0, output_tokens: 0 }, + model_usage: [], + }, + }, + }; + } + } + + let totalInputTokens = 0; + let totalOutputTokens = 0; + const modelUsageMap = new Map(); + let nextMessage: Content | null = { role: 'user', parts }; while (nextMessage !== null) { @@ -538,8 +727,8 @@ export class Session { try { const model = resolveModel( - this.config.getModel(), - (await this.config.getGemini31Launched?.()) ?? false, + this.context.config.getModel(), + (await this.context.config.getGemini31Launched?.()) ?? false, ); const responseStream = await chat.sendMessageStream( { model }, @@ -550,11 +739,25 @@ export class Session { ); nextMessage = null; + let turnInputTokens = 0; + let turnOutputTokens = 0; + let turnModelId = model; + for await (const resp of responseStream) { if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } + if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { + turnInputTokens = + resp.value.usageMetadata.promptTokenCount ?? turnInputTokens; + turnOutputTokens = + resp.value.usageMetadata.candidatesTokenCount ?? turnOutputTokens; + if (resp.value.modelVersion) { + turnModelId = resp.value.modelVersion; + } + } + if ( resp.type === StreamEventType.CHUNK && resp.value.candidates && @@ -586,6 +789,19 @@ export class Session { } } + totalInputTokens += turnInputTokens; + totalOutputTokens += turnOutputTokens; + + if (turnInputTokens > 0 || turnOutputTokens > 0) { + const existing = modelUsageMap.get(turnModelId) ?? { + input: 0, + output: 0, + }; + existing.input += turnInputTokens; + existing.output += turnOutputTokens; + modelUsageMap.set(turnModelId, existing); + } + if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } @@ -622,12 +838,52 @@ export class Session { } } - return { stopReason: 'end_turn' }; + const modelUsageArray = Array.from(modelUsageMap.entries()).map( + ([modelName, counts]) => ({ + model: modelName, + token_count: { + input_tokens: counts.input, + output_tokens: counts.output, + }, + }), + ); + + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + }, + model_usage: modelUsageArray, + }, + }, + }; } - private async sendUpdate( - update: acp.SessionNotification['update'], - ): Promise { + private async handleCommand( + commandText: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + parts: Part[], + ): Promise { + const gitService = await this.context.config.getGitService(); + const commandContext = { + agentContext: this.context, + settings: this.settings, + git: gitService, + sendMessage: async (text: string) => { + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + }); + }, + }; + + return this.commandHandler.handleCommand(commandText, commandContext); + } + + private async sendUpdate(update: acp.SessionUpdate): Promise { const params: acp.SessionNotification = { sessionId: this.id, update, @@ -649,7 +905,7 @@ export class Session { const errorResponse = (error: Error) => { const durationMs = Date.now() - startTime; logToolCall( - this.config, + this.context.config, new ToolCallEvent( undefined, fc.name ?? '', @@ -679,7 +935,7 @@ export class Session { return errorResponse(new Error('Missing function name')); } - const toolRegistry = this.config.getToolRegistry(); + const toolRegistry = this.context.toolRegistry; const tool = toolRegistry.getTool(fc.name); if (!tool) { @@ -700,7 +956,7 @@ export class Session { if (confirmationDetails.type === 'edit') { content.push({ type: 'diff', - path: confirmationDetails.fileName, + path: confirmationDetails.filePath, oldText: confirmationDetails.originalContent, newText: confirmationDetails.newContent, _meta: { @@ -715,7 +971,10 @@ export class Session { const params: acp.RequestPermissionRequest = { sessionId: this.id, - options: toPermissionOptions(confirmationDetails), + options: toPermissionOptions( + confirmationDetails, + this.context.config, + ), toolCall: { toolCallId: callId, status: 'pending', @@ -773,12 +1032,15 @@ export class Session { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', + title: invocation.getDescription(), content: content ? [content] : [], + locations: invocation.toolLocations(), + kind: toAcpToolKind(tool.kind), }); const durationMs = Date.now() - startTime; logToolCall( - this.config, + this.context.config, new ToolCallEvent( undefined, fc.name ?? '', @@ -792,7 +1054,7 @@ export class Session { ), ); - this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [ + this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ { status: CoreToolCallStatus.Success, request: { @@ -810,7 +1072,8 @@ export class Session { fc.name, callId, toolResult.llmContent, - this.config.getActiveModel(), + this.context.config.getActiveModel(), + this.context.config, ), resultDisplay: toolResult.returnDisplay, error: undefined, @@ -823,7 +1086,8 @@ export class Session { fc.name, callId, toolResult.llmContent, - this.config.getActiveModel(), + this.context.config.getActiveModel(), + this.context.config, ); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); @@ -835,9 +1099,10 @@ export class Session { content: [ { type: 'content', content: { type: 'text', text: error.message } }, ], + kind: toAcpToolKind(tool.kind), }); - this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [ + this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ { status: CoreToolCallStatus.Error, request: { @@ -923,18 +1188,18 @@ export class Session { const atPathToResolvedSpecMap = new Map(); // Get centralized file discovery service - const fileDiscovery = this.config.getFileService(); + const fileDiscovery = this.context.config.getFileService(); const fileFilteringOptions: FilterFilesOptions = - this.config.getFileFilteringOptions(); + this.context.config.getFileFilteringOptions(); const pathSpecsToRead: string[] = []; const contentLabelsForDisplay: string[] = []; const ignoredPaths: string[] = []; - const toolRegistry = this.config.getToolRegistry(); + const toolRegistry = this.context.toolRegistry; const readManyFilesTool = new ReadManyFilesTool( - this.config, - this.config.getMessageBus(), + this.context.config, + this.context.messageBus, ); const globTool = toolRegistry.getTool('glob'); @@ -953,8 +1218,11 @@ export class Session { let currentPathSpec = pathName; let resolvedSuccessfully = false; try { - const absolutePath = path.resolve(this.config.getTargetDir(), pathName); - if (isWithinRoot(absolutePath, this.config.getTargetDir())) { + const absolutePath = path.resolve( + this.context.config.getTargetDir(), + pathName, + ); + if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) { const stats = await fs.stat(absolutePath); if (stats.isDirectory()) { currentPathSpec = pathName.endsWith('/') @@ -974,7 +1242,7 @@ export class Session { } } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { - if (this.config.getEnableRecursiveFileSearch() && globTool) { + if (this.context.config.getEnableRecursiveFileSearch() && globTool) { this.debug( `Path ${pathName} not found directly, attempting glob search.`, ); @@ -982,7 +1250,7 @@ export class Session { const globResult = await globTool.buildAndExecute( { pattern: `**/*${pathName}*`, - path: this.config.getTargetDir(), + path: this.context.config.getTargetDir(), }, abortSignal, ); @@ -996,7 +1264,7 @@ export class Session { if (lines.length > 1 && lines[1]) { const firstMatchAbsolute = lines[1].trim(); currentPathSpec = path.relative( - this.config.getTargetDir(), + this.context.config.getTargetDir(), firstMatchAbsolute, ); this.debug( @@ -1129,7 +1397,10 @@ export class Session { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', + title: invocation.getDescription(), content: content ? [content] : [], + locations: invocation.toolLocations(), + kind: toAcpToolKind(readManyFilesTool.kind), }); if (Array.isArray(result.llmContent)) { const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; @@ -1173,6 +1444,7 @@ export class Session { }, }, ], + kind: toAcpToolKind(readManyFilesTool.kind), }); throw error; @@ -1207,7 +1479,7 @@ export class Session { } debug(msg: string) { - if (this.config.getDebugMode()) { + if (this.context.config.getDebugMode()) { debugLogger.warn(msg); } } @@ -1228,7 +1500,9 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null { if ('fileName' in toolResult.returnDisplay) { return { type: 'diff', - path: toolResult.returnDisplay.fileName, + path: + toolResult.returnDisplay.filePath ?? + toolResult.returnDisplay.fileName, oldText: toolResult.returnDisplay.originalContent, newText: toolResult.returnDisplay.newContent, _meta: { @@ -1262,60 +1536,76 @@ const basicPermissionOptions = [ function toPermissionOptions( confirmation: ToolCallConfirmationDetails, + config: Config, ): acp.PermissionOption[] { - switch (confirmation.type) { - case 'edit': - return [ - { + const disableAlwaysAllow = config.getDisableAlwaysAllow(); + const options: acp.PermissionOption[] = []; + + if (!disableAlwaysAllow) { + switch (confirmation.type) { + case 'edit': + options.push({ optionId: ToolConfirmationOutcome.ProceedAlways, name: 'Allow All Edits', kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - case 'exec': - return [ - { + }); + break; + case 'exec': + options.push({ optionId: ToolConfirmationOutcome.ProceedAlways, name: `Always Allow ${confirmation.rootCommand}`, kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - case 'mcp': - return [ - { - optionId: ToolConfirmationOutcome.ProceedAlwaysServer, - name: `Always Allow ${confirmation.serverName}`, - kind: 'allow_always', - }, - { - optionId: ToolConfirmationOutcome.ProceedAlwaysTool, - name: `Always Allow ${confirmation.toolName}`, - kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - case 'info': - return [ - { + }); + break; + case 'mcp': + options.push( + { + optionId: ToolConfirmationOutcome.ProceedAlwaysServer, + name: `Always Allow ${confirmation.serverName}`, + kind: 'allow_always', + }, + { + optionId: ToolConfirmationOutcome.ProceedAlwaysTool, + name: `Always Allow ${confirmation.toolName}`, + kind: 'allow_always', + }, + ); + break; + case 'info': + options.push({ optionId: ToolConfirmationOutcome.ProceedAlways, name: `Always Allow`, kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; + }); + break; + case 'ask_user': + case 'exit_plan_mode': + // askuser and exit_plan_mode don't need "always allow" options + break; + default: + // No "always allow" options for other types + break; + } + } + + options.push(...basicPermissionOptions); + + // Exhaustive check + switch (confirmation.type) { + case 'edit': + case 'exec': + case 'mcp': + case 'info': case 'ask_user': - // askuser doesn't need "always allow" options since it's asking questions - return [...basicPermissionOptions]; case 'exit_plan_mode': - // exit_plan_mode doesn't need "always allow" options since it's a plan approval flow - return [...basicPermissionOptions]; + break; default: { const unreachable: never = confirmation; throw new Error(`Unexpected: ${unreachable}`); } } + + return options; } /** @@ -1373,3 +1663,94 @@ function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] { return modes; } + +function buildAvailableModels( + config: Config, + settings: LoadedSettings, +): { + availableModels: Array<{ + modelId: string; + name: string; + description?: string; + }>; + currentModelId: string; +} { + const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO; + const shouldShowPreviewModels = config.getHasAccessToPreviewModel(); + const useGemini31 = config.getGemini31LaunchedSync?.() ?? false; + const selectedAuthType = settings.merged.security.auth.selectedType; + const useCustomToolModel = + useGemini31 && selectedAuthType === AuthType.USE_GEMINI; + + const mainOptions = [ + { + value: DEFAULT_GEMINI_MODEL_AUTO, + title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO), + description: + 'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash', + }, + ]; + + if (shouldShowPreviewModels) { + mainOptions.unshift({ + value: PREVIEW_GEMINI_MODEL_AUTO, + title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO), + description: useGemini31 + ? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash' + : 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', + }); + } + + const manualOptions = [ + { + value: DEFAULT_GEMINI_MODEL, + title: getDisplayString(DEFAULT_GEMINI_MODEL), + }, + { + value: DEFAULT_GEMINI_FLASH_MODEL, + title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL), + }, + { + value: DEFAULT_GEMINI_FLASH_LITE_MODEL, + title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL), + }, + ]; + + if (shouldShowPreviewModels) { + const previewProModel = useGemini31 + ? PREVIEW_GEMINI_3_1_MODEL + : PREVIEW_GEMINI_MODEL; + + const previewProValue = useCustomToolModel + ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + : previewProModel; + + manualOptions.unshift( + { + value: previewProValue, + title: getDisplayString(previewProModel), + }, + { + value: PREVIEW_GEMINI_FLASH_MODEL, + title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL), + }, + ); + } + + const scaleOptions = ( + options: Array<{ value: string; title: string; description?: string }>, + ) => + options.map((o) => ({ + modelId: o.value, + name: o.title, + description: o.description, + })); + + return { + availableModels: [ + ...scaleOptions(mainOptions), + ...scaleOptions(manualOptions), + ], + currentModelId: preferredModel, + }; +} diff --git a/packages/cli/src/zed-integration/acpErrors.test.ts b/packages/cli/src/acp/acpErrors.test.ts similarity index 100% rename from packages/cli/src/zed-integration/acpErrors.test.ts rename to packages/cli/src/acp/acpErrors.test.ts diff --git a/packages/cli/src/zed-integration/acpErrors.ts b/packages/cli/src/acp/acpErrors.ts similarity index 100% rename from packages/cli/src/zed-integration/acpErrors.ts rename to packages/cli/src/acp/acpErrors.ts diff --git a/packages/cli/src/zed-integration/acpResume.test.ts b/packages/cli/src/acp/acpResume.test.ts similarity index 91% rename from packages/cli/src/zed-integration/acpResume.test.ts rename to packages/cli/src/acp/acpResume.test.ts index 54c04a0ff3..77021004ca 100644 --- a/packages/cli/src/zed-integration/acpResume.test.ts +++ b/packages/cli/src/acp/acpResume.test.ts @@ -13,7 +13,7 @@ import { type Mocked, type Mock, } from 'vitest'; -import { GeminiAgent } from './zedIntegration.js'; +import { GeminiAgent } from './acpClient.js'; import * as acp from '@agentclientprotocol/sdk'; import { ApprovalMode, @@ -92,7 +92,14 @@ describe('GeminiAgent Session Resume', () => { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), }, getApprovalMode: vi.fn().mockReturnValue('default'), - isPlanEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), + getModel: vi.fn().mockReturnValue('gemini-pro'), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), + getGemini31LaunchedSync: vi.fn().mockReturnValue(false), + getCheckpointingEnabled: vi.fn().mockReturnValue(false), + get config() { + return this; + }, } as unknown as Mocked; mockSettings = { merged: { @@ -154,9 +161,10 @@ describe('GeminiAgent Session Resume', () => { ], }; - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockConfig as any).toolRegistry = { getTool: vi.fn().mockReturnValue({ kind: 'read' }), - }); + }; (SessionSelector as unknown as Mock).mockImplementation(() => ({ resolveSession: vi.fn().mockResolvedValue({ @@ -200,9 +208,18 @@ describe('GeminiAgent Session Resume', () => { name: 'YOLO', description: 'Auto-approves all tools', }, + { + id: ApprovalMode.PLAN, + name: 'Plan', + description: 'Read-only mode', + }, ], currentModeId: ApprovalMode.DEFAULT, }, + models: { + availableModels: expect.any(Array) as unknown, + currentModelId: 'gemini-pro', + }, }); // Verify resumeChat received the correct arguments diff --git a/packages/cli/src/acp/commandHandler.test.ts b/packages/cli/src/acp/commandHandler.test.ts new file mode 100644 index 0000000000..8e04f014f3 --- /dev/null +++ b/packages/cli/src/acp/commandHandler.test.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandHandler } from './commandHandler.js'; +import { describe, it, expect } from 'vitest'; + +describe('CommandHandler', () => { + it('parses commands correctly', () => { + const handler = new CommandHandler(); + // @ts-expect-error - testing private method + const parse = (query: string) => handler.parseSlashCommand(query); + + const memShow = parse('/memory show'); + expect(memShow.commandToExecute?.name).toBe('memory show'); + expect(memShow.args).toBe(''); + + const memAdd = parse('/memory add hello world'); + expect(memAdd.commandToExecute?.name).toBe('memory add'); + expect(memAdd.args).toBe('hello world'); + + const extList = parse('/extensions list'); + expect(extList.commandToExecute?.name).toBe('extensions list'); + + const init = parse('/init'); + expect(init.commandToExecute?.name).toBe('init'); + }); +}); diff --git a/packages/cli/src/acp/commandHandler.ts b/packages/cli/src/acp/commandHandler.ts new file mode 100644 index 0000000000..836cdf7736 --- /dev/null +++ b/packages/cli/src/acp/commandHandler.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Command, CommandContext } from './commands/types.js'; +import { CommandRegistry } from './commands/commandRegistry.js'; +import { MemoryCommand } from './commands/memory.js'; +import { ExtensionsCommand } from './commands/extensions.js'; +import { InitCommand } from './commands/init.js'; +import { RestoreCommand } from './commands/restore.js'; + +export class CommandHandler { + private registry: CommandRegistry; + + constructor() { + this.registry = CommandHandler.createRegistry(); + } + + private static createRegistry(): CommandRegistry { + const registry = new CommandRegistry(); + registry.register(new MemoryCommand()); + registry.register(new ExtensionsCommand()); + registry.register(new InitCommand()); + registry.register(new RestoreCommand()); + return registry; + } + + getAvailableCommands(): Array<{ name: string; description: string }> { + return this.registry.getAllCommands().map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })); + } + + /** + * Parses and executes a command string if it matches a registered command. + * Returns true if a command was handled, false otherwise. + */ + async handleCommand( + commandText: string, + context: CommandContext, + ): Promise { + const { commandToExecute, args } = this.parseSlashCommand(commandText); + + if (commandToExecute) { + await this.runCommand(commandToExecute, args, context); + return true; + } + + return false; + } + + private async runCommand( + commandToExecute: Command, + args: string, + context: CommandContext, + ): Promise { + try { + const result = await commandToExecute.execute( + context, + args ? args.split(/\s+/) : [], + ); + + let messageContent = ''; + if (typeof result.data === 'string') { + messageContent = result.data; + } else if ( + typeof result.data === 'object' && + result.data !== null && + 'content' in result.data + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any + messageContent = (result.data as Record)[ + 'content' + ] as string; + } else { + messageContent = JSON.stringify(result.data, null, 2); + } + + await context.sendMessage(messageContent); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await context.sendMessage(`Error: ${errorMessage}`); + } + } + + /** + * Parses a raw slash command string into its matching headless command and arguments. + * Mirrors `packages/cli/src/utils/commands.ts` logic. + */ + private parseSlashCommand(query: string): { + commandToExecute: Command | undefined; + args: string; + } { + const trimmed = query.trim(); + const parts = trimmed.substring(1).trim().split(/\s+/); + const commandPath = parts.filter((p) => p); + + let currentCommands = this.registry.getAllCommands(); + let commandToExecute: Command | undefined; + let pathIndex = 0; + + for (const part of commandPath) { + const foundCommand = currentCommands.find((cmd) => { + const expectedName = commandPath.slice(0, pathIndex + 1).join(' '); + return ( + cmd.name === part || + cmd.name === expectedName || + cmd.aliases?.includes(part) || + cmd.aliases?.includes(expectedName) + ); + }); + + if (foundCommand) { + commandToExecute = foundCommand; + pathIndex++; + if (foundCommand.subCommands) { + currentCommands = foundCommand.subCommands; + } else { + break; + } + } else { + break; + } + } + + const args = parts.slice(pathIndex).join(' '); + + return { commandToExecute, args }; + } +} diff --git a/packages/cli/src/acp/commands/commandRegistry.ts b/packages/cli/src/acp/commands/commandRegistry.ts new file mode 100644 index 0000000000..b689d5d602 --- /dev/null +++ b/packages/cli/src/acp/commands/commandRegistry.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '@google/gemini-cli-core'; +import type { Command } from './types.js'; + +export class CommandRegistry { + private readonly commands = new Map(); + + register(command: Command) { + if (this.commands.has(command.name)) { + debugLogger.warn(`Command ${command.name} already registered. Skipping.`); + return; + } + + this.commands.set(command.name, command); + + for (const subCommand of command.subCommands ?? []) { + this.register(subCommand); + } + } + + get(commandName: string): Command | undefined { + return this.commands.get(commandName); + } + + getAllCommands(): Command[] { + return [...this.commands.values()]; + } +} diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts new file mode 100644 index 0000000000..a6e08f9bbc --- /dev/null +++ b/packages/cli/src/acp/commands/extensions.ts @@ -0,0 +1,448 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + listExtensions, + type Config, + getErrorMessage, +} from '@google/gemini-cli-core'; +import { SettingScope } from '../../config/settings.js'; +import { + ExtensionManager, + inferInstallMetadata, +} from '../../config/extension-manager.js'; +import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; +import { stat } from 'node:fs/promises'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class ExtensionsCommand implements Command { + readonly name = 'extensions'; + readonly description = 'Manage extensions.'; + readonly subCommands = [ + new ListExtensionsCommand(), + new ExploreExtensionsCommand(), + new EnableExtensionCommand(), + new DisableExtensionCommand(), + new InstallExtensionCommand(), + new LinkExtensionCommand(), + new UninstallExtensionCommand(), + new RestartExtensionCommand(), + new UpdateExtensionCommand(), + ]; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + return new ListExtensionsCommand().execute(context, _); + } +} + +export class ListExtensionsCommand implements Command { + readonly name = 'extensions list'; + readonly description = 'Lists all installed extensions.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const extensions = listExtensions(context.agentContext.config); + const data = extensions.length ? extensions : 'No extensions installed.'; + + return { name: this.name, data }; + } +} + +export class ExploreExtensionsCommand implements Command { + readonly name = 'extensions explore'; + readonly description = 'Explore available extensions.'; + + async execute( + _context: CommandContext, + _: string[], + ): Promise { + const extensionsUrl = 'https://geminicli.com/extensions/'; + return { + name: this.name, + data: `View or install available extensions at ${extensionsUrl}`, + }; + } +} + +function getEnableDisableContext( + config: Config, + args: string[], + invocationName: string, +) { + const extensionManager = config.getExtensionLoader(); + if (!(extensionManager instanceof ExtensionManager)) { + return { + error: `Cannot ${invocationName} extensions in this environment.`, + }; + } + + if (args.length === 0) { + return { + error: `Usage: /extensions ${invocationName} [--scope=]`, + }; + } + + let scope = SettingScope.User; + if (args.includes('--scope=workspace') || args.includes('workspace')) { + scope = SettingScope.Workspace; + } else if (args.includes('--scope=session') || args.includes('session')) { + scope = SettingScope.Session; + } + + const name = args.filter( + (a) => + !a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a), + )[0]; + + let names: string[] = []; + if (name === '--all') { + let extensions = extensionManager.getExtensions(); + if (invocationName === 'enable') { + extensions = extensions.filter((ext) => !ext.isActive); + } + if (invocationName === 'disable') { + extensions = extensions.filter((ext) => ext.isActive); + } + names = extensions.map((ext) => ext.name); + } else if (name) { + names = [name]; + } else { + return { error: 'No extension name provided.' }; + } + + return { extensionManager, names, scope }; +} + +export class EnableExtensionCommand implements Command { + readonly name = 'extensions enable'; + readonly description = 'Enable an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const enableContext = getEnableDisableContext( + context.agentContext.config, + args, + 'enable', + ); + if ('error' in enableContext) { + return { name: this.name, data: enableContext.error }; + } + + const { names, scope, extensionManager } = enableContext; + const output: string[] = []; + + for (const name of names) { + try { + await extensionManager.enableExtension(name, scope); + output.push(`Extension "${name}" enabled for scope "${scope}".`); + + const extension = extensionManager + .getExtensions() + .find((e) => e.name === name); + + if (extension?.mcpServers) { + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const mcpClientManager = + context.agentContext.config.getMcpClientManager(); + const enabledServers = await mcpEnablementManager.autoEnableServers( + Object.keys(extension.mcpServers), + ); + + if (mcpClientManager && enabledServers.length > 0) { + const restartPromises = enabledServers.map((serverName) => + mcpClientManager.restartServer(serverName).catch((error) => { + output.push( + `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`, + ); + }), + ); + await Promise.all(restartPromises); + output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`); + } + } + } catch (e) { + output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`); + } + } + + return { name: this.name, data: output.join('\n') || 'No action taken.' }; + } +} + +export class DisableExtensionCommand implements Command { + readonly name = 'extensions disable'; + readonly description = 'Disable an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const enableContext = getEnableDisableContext( + context.agentContext.config, + args, + 'disable', + ); + if ('error' in enableContext) { + return { name: this.name, data: enableContext.error }; + } + + const { names, scope, extensionManager } = enableContext; + const output: string[] = []; + + for (const name of names) { + try { + await extensionManager.disableExtension(name, scope); + output.push(`Extension "${name}" disabled for scope "${scope}".`); + } catch (e) { + output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`); + } + } + + return { name: this.name, data: output.join('\n') || 'No action taken.' }; + } +} + +export class InstallExtensionCommand implements Command { + readonly name = 'extensions install'; + readonly description = 'Install an extension from a git repo or local path.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { + name: this.name, + data: 'Cannot install extensions in this environment.', + }; + } + + const source = args.join(' ').trim(); + if (!source) { + return { name: this.name, data: `Usage: /extensions install ` }; + } + + if (/[;&|`'"]/.test(source)) { + return { + name: this.name, + data: `Invalid source: contains disallowed characters.`, + }; + } + + try { + const installMetadata = await inferInstallMetadata(source); + const extension = + await extensionLoader.installOrUpdateExtension(installMetadata); + return { + name: this.name, + data: `Extension "${extension.name}" installed successfully.`, + }; + } catch (error) { + return { + name: this.name, + data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`, + }; + } + } +} + +export class LinkExtensionCommand implements Command { + readonly name = 'extensions link'; + readonly description = 'Link an extension from a local path.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { + name: this.name, + data: 'Cannot link extensions in this environment.', + }; + } + + const sourceFilepath = args.join(' ').trim(); + if (!sourceFilepath) { + return { name: this.name, data: `Usage: /extensions link ` }; + } + + try { + await stat(sourceFilepath); + } catch (_error) { + return { name: this.name, data: `Invalid source: ${sourceFilepath}` }; + } + + try { + const extension = await extensionLoader.installOrUpdateExtension({ + source: sourceFilepath, + type: 'link', + }); + return { + name: this.name, + data: `Extension "${extension.name}" linked successfully.`, + }; + } catch (error) { + return { + name: this.name, + data: `Failed to link extension: ${getErrorMessage(error)}`, + }; + } + } +} + +export class UninstallExtensionCommand implements Command { + readonly name = 'extensions uninstall'; + readonly description = 'Uninstall an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { + name: this.name, + data: 'Cannot uninstall extensions in this environment.', + }; + } + + const all = args.includes('--all'); + const names = args.filter((a) => !a.startsWith('--')).map((a) => a.trim()); + + if (!all && names.length === 0) { + return { + name: this.name, + data: `Usage: /extensions uninstall |--all`, + }; + } + + let namesToUninstall: string[] = []; + if (all) { + namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name); + } else { + namesToUninstall = names; + } + + if (namesToUninstall.length === 0) { + return { + name: this.name, + data: all ? 'No extensions installed.' : 'No extension name provided.', + }; + } + + const output: string[] = []; + for (const extensionName of namesToUninstall) { + try { + await extensionLoader.uninstallExtension(extensionName, false); + output.push(`Extension "${extensionName}" uninstalled successfully.`); + } catch (error) { + output.push( + `Failed to uninstall extension "${extensionName}": ${getErrorMessage(error)}`, + ); + } + } + + return { name: this.name, data: output.join('\n') }; + } +} + +export class RestartExtensionCommand implements Command { + readonly name = 'extensions restart'; + readonly description = 'Restart an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { name: this.name, data: 'Cannot restart extensions.' }; + } + + const all = args.includes('--all'); + const names = all ? null : args.filter((a) => !!a); + + if (!all && names?.length === 0) { + return { + name: this.name, + data: 'Usage: /extensions restart |--all', + }; + } + + let extensionsToRestart = extensionLoader + .getExtensions() + .filter((e) => e.isActive); + if (names) { + extensionsToRestart = extensionsToRestart.filter((e) => + names.includes(e.name), + ); + } + + if (extensionsToRestart.length === 0) { + return { + name: this.name, + data: 'No active extensions matched the request.', + }; + } + + const output: string[] = []; + for (const extension of extensionsToRestart) { + try { + await extensionLoader.restartExtension(extension); + output.push(`Restarted "${extension.name}".`); + } catch (e) { + output.push( + `Failed to restart "${extension.name}": ${getErrorMessage(e)}`, + ); + } + } + + return { name: this.name, data: output.join('\n') }; + } +} + +export class UpdateExtensionCommand implements Command { + readonly name = 'extensions update'; + readonly description = 'Update an extension.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const extensionLoader = context.agentContext.config.getExtensionLoader(); + if (!(extensionLoader instanceof ExtensionManager)) { + return { name: this.name, data: 'Cannot update extensions.' }; + } + + const all = args.includes('--all'); + const names = all ? null : args.filter((a) => !!a); + + if (!all && names?.length === 0) { + return { + name: this.name, + data: 'Usage: /extensions update |--all', + }; + } + + return { + name: this.name, + data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.', + }; + } +} diff --git a/packages/cli/src/acp/commands/init.ts b/packages/cli/src/acp/commands/init.ts new file mode 100644 index 0000000000..a9104aa84f --- /dev/null +++ b/packages/cli/src/acp/commands/init.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { performInit } from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class InitCommand implements Command { + name = 'init'; + description = 'Analyzes the project and creates a tailored GEMINI.md file'; + requiresWorkspace = true; + + async execute( + context: CommandContext, + _args: string[] = [], + ): Promise { + const targetDir = context.agentContext.config.getTargetDir(); + if (!targetDir) { + throw new Error('Command requires a workspace.'); + } + + const geminiMdPath = path.join(targetDir, 'GEMINI.md'); + const result = performInit(fs.existsSync(geminiMdPath)); + + switch (result.type) { + case 'message': + return { + name: this.name, + data: result, + }; + case 'submit_prompt': + fs.writeFileSync(geminiMdPath, '', 'utf8'); + + if (typeof result.content !== 'string') { + throw new Error('Init command content must be a string.'); + } + + // Inform the user since we can't trigger the UI-based interactive agent loop here directly. + // We output the prompt text they can use to re-trigger the generation manually, + // or just seed the GEMINI.md file as we've done above. + return { + name: this.name, + data: { + type: 'message', + messageType: 'info', + content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`, + }, + }; + + default: + throw new Error('Unknown result type from performInit'); + } + } +} diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts new file mode 100644 index 0000000000..ac919f2a9b --- /dev/null +++ b/packages/cli/src/acp/commands/memory.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + addMemory, + listMemoryFiles, + refreshMemory, + showMemory, +} from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +const DEFAULT_SANITIZATION_CONFIG = { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, +}; + +export class MemoryCommand implements Command { + readonly name = 'memory'; + readonly description = 'Manage memory.'; + readonly subCommands = [ + new ShowMemoryCommand(), + new RefreshMemoryCommand(), + new ListMemoryCommand(), + new AddMemoryCommand(), + ]; + readonly requiresWorkspace = true; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + return new ShowMemoryCommand().execute(context, _); + } +} + +export class ShowMemoryCommand implements Command { + readonly name = 'memory show'; + readonly description = 'Shows the current memory contents.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = showMemory(context.agentContext.config); + return { name: this.name, data: result.content }; + } +} + +export class RefreshMemoryCommand implements Command { + readonly name = 'memory refresh'; + readonly aliases = ['memory reload']; + readonly description = 'Refreshes the memory from the source.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = await refreshMemory(context.agentContext.config); + return { name: this.name, data: result.content }; + } +} + +export class ListMemoryCommand implements Command { + readonly name = 'memory list'; + readonly description = 'Lists the paths of the GEMINI.md files in use.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = listMemoryFiles(context.agentContext.config); + return { name: this.name, data: result.content }; + } +} + +export class AddMemoryCommand implements Command { + readonly name = 'memory add'; + readonly description = 'Add content to the memory.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const textToAdd = args.join(' ').trim(); + const result = addMemory(textToAdd); + if (result.type === 'message') { + return { name: this.name, data: result.content }; + } + + const toolRegistry = context.agentContext.toolRegistry; + const tool = toolRegistry.getTool(result.toolName); + if (tool) { + const abortController = new AbortController(); + const signal = abortController.signal; + + await context.sendMessage(`Saving memory via ${result.toolName}...`); + + await tool.buildAndExecute(result.toolArgs, signal, undefined, { + shellExecutionConfig: { + sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + sandboxManager: context.agentContext.sandboxManager, + }, + }); + await refreshMemory(context.agentContext.config); + return { + name: this.name, + data: `Added memory: "${textToAdd}"`, + }; + } else { + return { + name: this.name, + data: `Error: Tool ${result.toolName} not found.`, + }; + } + } +} diff --git a/packages/cli/src/acp/commands/restore.ts b/packages/cli/src/acp/commands/restore.ts new file mode 100644 index 0000000000..6898cff2e1 --- /dev/null +++ b/packages/cli/src/acp/commands/restore.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getCheckpointInfoList, + getToolCallDataSchema, + isNodeError, + performRestore, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class RestoreCommand implements Command { + readonly name = 'restore'; + readonly description = + 'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created'; + readonly requiresWorkspace = true; + readonly subCommands = [new ListCheckpointsCommand()]; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const { agentContext: agentContext, git: gitService } = context; + const { config } = agentContext; + const argsStr = args.join(' '); + + try { + if (!argsStr) { + return await new ListCheckpointsCommand().execute(context); + } + + if (!config.getCheckpointingEnabled()) { + return { + name: this.name, + data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.', + }; + } + + const selectedFile = argsStr.endsWith('.json') + ? argsStr + : `${argsStr}.json`; + + const checkpointDir = config.storage.getProjectTempCheckpointsDir(); + const filePath = path.join(checkpointDir, selectedFile); + + let data: string; + try { + data = await fs.readFile(filePath, 'utf-8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return { + name: this.name, + data: `File not found: ${selectedFile}`, + }; + } + throw error; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const toolCallData = JSON.parse(data); + const ToolCallDataSchema = getToolCallDataSchema(); + const parseResult = ToolCallDataSchema.safeParse(toolCallData); + + if (!parseResult.success) { + return { + name: this.name, + data: 'Checkpoint file is invalid or corrupted.', + }; + } + + const restoreResultGenerator = performRestore( + parseResult.data, + gitService, + ); + + const restoreResult = []; + for await (const result of restoreResultGenerator) { + restoreResult.push(result); + } + + // Format the result nicely since Zed just dumps data + const formattedResult = restoreResult + .map((r) => { + if (r.type === 'message') { + return `[${r.messageType.toUpperCase()}] ${r.content}`; + } else if (r.type === 'load_history') { + return `Loaded history with ${r.clientHistory.length} messages.`; + } + return `Restored: ${JSON.stringify(r)}`; + }) + .join('\n'); + + return { + name: this.name, + data: formattedResult, + }; + } catch (error) { + return { + name: this.name, + data: `An unexpected error occurred during restore: ${error}`, + }; + } + } +} + +export class ListCheckpointsCommand implements Command { + readonly name = 'restore list'; + readonly description = 'Lists all available checkpoints.'; + + async execute(context: CommandContext): Promise { + const { config } = context.agentContext; + + try { + if (!config.getCheckpointingEnabled()) { + return { + name: this.name, + data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.', + }; + } + + const checkpointDir = config.storage.getProjectTempCheckpointsDir(); + try { + await fs.mkdir(checkpointDir, { recursive: true }); + } catch (_e) { + // Ignore + } + + const files = await fs.readdir(checkpointDir); + const jsonFiles = files.filter((file) => file.endsWith('.json')); + + if (jsonFiles.length === 0) { + return { name: this.name, data: 'No checkpoints found.' }; + } + + const checkpointFiles = new Map(); + for (const file of jsonFiles) { + const filePath = path.join(checkpointDir, file); + const data = await fs.readFile(filePath, 'utf-8'); + checkpointFiles.set(file, data); + } + + const checkpointInfoList = getCheckpointInfoList(checkpointFiles); + + const formatted = checkpointInfoList + .map((info) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const i = info as Record; + const fileName = String(i['fileName'] || 'Unknown'); + const toolName = String(i['toolName'] || 'Unknown'); + const status = String(i['status'] || 'Unknown'); + const timestamp = new Date( + Number(i['timestamp']) || 0, + ).toLocaleString(); + + return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`; + }) + .join('\n'); + + return { + name: this.name, + data: `Available Checkpoints:\n${formatted}`, + }; + } catch (_error) { + return { + name: this.name, + data: 'An unexpected error occurred while listing checkpoints.', + }; + } + } +} diff --git a/packages/cli/src/acp/commands/types.ts b/packages/cli/src/acp/commands/types.ts new file mode 100644 index 0000000000..6f5656bd89 --- /dev/null +++ b/packages/cli/src/acp/commands/types.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AgentLoopContext, GitService } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../../config/settings.js'; + +export interface CommandContext { + agentContext: AgentLoopContext; + settings: LoadedSettings; + git?: GitService; + sendMessage: (text: string) => Promise; +} + +export interface CommandArgument { + readonly name: string; + readonly description: string; + readonly isRequired?: boolean; +} + +export interface Command { + readonly name: string; + readonly aliases?: string[]; + readonly description: string; + readonly arguments?: CommandArgument[]; + readonly subCommands?: Command[]; + readonly requiresWorkspace?: boolean; + + execute( + context: CommandContext, + args: string[], + ): Promise; +} + +export interface CommandExecutionResponse { + readonly name: string; + readonly data: unknown; +} diff --git a/packages/cli/src/zed-integration/fileSystemService.test.ts b/packages/cli/src/acp/fileSystemService.test.ts similarity index 100% rename from packages/cli/src/zed-integration/fileSystemService.test.ts rename to packages/cli/src/acp/fileSystemService.test.ts diff --git a/packages/cli/src/zed-integration/fileSystemService.ts b/packages/cli/src/acp/fileSystemService.ts similarity index 95% rename from packages/cli/src/zed-integration/fileSystemService.ts rename to packages/cli/src/acp/fileSystemService.ts index 1d3c8ad0b8..02b9d68195 100644 --- a/packages/cli/src/zed-integration/fileSystemService.ts +++ b/packages/cli/src/acp/fileSystemService.ts @@ -14,7 +14,7 @@ export class AcpFileSystemService implements FileSystemService { constructor( private readonly connection: acp.AgentSideConnection, private readonly sessionId: string, - private readonly capabilities: acp.FileSystemCapability, + private readonly capabilities: acp.FileSystemCapabilities, private readonly fallback: FileSystemService, ) {} diff --git a/packages/cli/src/commands/extensions/disable.test.ts b/packages/cli/src/commands/extensions/disable.test.ts index 341fbaf9f0..47fc1190c0 100644 --- a/packages/cli/src/commands/extensions/disable.test.ts +++ b/packages/cli/src/commands/extensions/disable.test.ts @@ -22,7 +22,7 @@ import { SettingScope, type LoadedSettings, } from '../../config/settings.js'; -import { getErrorMessage } from '../../utils/errors.js'; +import { getErrorMessage } from '@google/gemini-cli-core'; // Mock dependencies const emitConsoleLog = vi.hoisted(() => vi.fn()); @@ -44,12 +44,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { emitConsoleLog, }, debugLogger, + getErrorMessage: vi.fn(), }; }); vi.mock('../../config/extension-manager.js'); vi.mock('../../config/settings.js'); -vi.mock('../../utils/errors.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index cdbc6a0ed4..dae97ea584 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -6,8 +6,7 @@ import { type CommandModule } from 'yargs'; import { loadSettings, SettingScope } from '../../config/settings.js'; -import { getErrorMessage } from '../../utils/errors.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; diff --git a/packages/cli/src/commands/extensions/examples/policies/README.md b/packages/cli/src/commands/extensions/examples/policies/README.md new file mode 100644 index 0000000000..d1c06de6e3 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/README.md @@ -0,0 +1,41 @@ +# Policy engine example extension + +This extension demonstrates how to contribute security rules and safety checkers +to the Gemini CLI Policy Engine. + +## Description + +The extension uses a `policies/` directory containing `.toml` files to define: + +- A rule that requires user confirmation for `rm -rf` commands. +- A rule that denies searching for sensitive files (like `.env`) using `grep`. +- A safety checker that validates file paths for all write operations. + +## Structure + +- `gemini-extension.json`: The manifest file. +- `policies/`: Contains the `.toml` policy files. + +## How to use + +1. Link this extension to your local Gemini CLI installation: + + ```bash + gemini extensions link packages/cli/src/commands/extensions/examples/policies + ``` + +2. Restart your Gemini CLI session. + +3. **Observe the policies:** + - Try asking the model to delete a directory: The policy engine will prompt + you for confirmation due to the `rm -rf` rule. + - Try asking the model to search for secrets: The `grep` rule will deny the + request and display the custom deny message. + - Any file write operation will now be processed through the `allowed-path` + safety checker. + +## Security note + +For security, Gemini CLI ignores any `allow` decisions or `yolo` mode +configurations contributed by extensions. This ensures that extensions can +strengthen security but cannot bypass user confirmation. diff --git a/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json b/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json new file mode 100644 index 0000000000..2a2b992532 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "policy-example", + "version": "1.0.0", + "description": "An example extension demonstrating Policy Engine support." +} diff --git a/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml b/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml new file mode 100644 index 0000000000..d89d5e5737 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml @@ -0,0 +1,28 @@ +# Example Policy Rules for Gemini CLI Extension +# +# Extensions run in Tier 2 (Extension Tier). +# Security Note: 'allow' decisions and 'yolo' mode configurations are ignored. + +# Rule: Always ask the user before running a specific dangerous shell command. +[[rule]] +toolName = "run_shell_command" +commandPrefix = "rm -rf" +decision = "ask_user" +priority = 100 + +# Rule: Deny access to sensitive files using the grep tool. +[[rule]] +toolName = "grep_search" +argsPattern = "(\.env|id_rsa|passwd)" +decision = "deny" +priority = 200 +deny_message = "Access to sensitive credentials or system files is restricted by the policy-example extension." + +# Safety Checker: Apply path validation to all write operations. +[[safety_checker]] +toolName = ["write_file", "replace"] +priority = 300 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +required_context = ["environment"] diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 7fa84fa868..8b3f8c5807 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -12,46 +12,46 @@ import { beforeEach, afterEach, type MockInstance, - type Mock, } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; import * as core from '@google/gemini-cli-core'; -import type { inferInstallMetadata } from '../../config/extension-manager.js'; -import { ExtensionManager } from '../../config/extension-manager.js'; -import type { - promptForConsentNonInteractive, - requestConsentNonInteractive, -} from '../../config/extensions/consent.js'; -import type { - isWorkspaceTrusted, - loadTrustedFolders, -} from '../../config/trustedFolders.js'; -import type * as fs from 'node:fs/promises'; import type { Stats } from 'node:fs'; import * as path from 'node:path'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; -const mockInstallOrUpdateExtension: Mock< - typeof ExtensionManager.prototype.installOrUpdateExtension -> = vi.hoisted(() => vi.fn()); -const mockRequestConsentNonInteractive: Mock< - typeof requestConsentNonInteractive -> = vi.hoisted(() => vi.fn()); -const mockPromptForConsentNonInteractive: Mock< - typeof promptForConsentNonInteractive -> = vi.hoisted(() => vi.fn()); -const mockStat: Mock = vi.hoisted(() => vi.fn()); -const mockInferInstallMetadata: Mock = vi.hoisted( - () => vi.fn(), -); -const mockIsWorkspaceTrusted: Mock = vi.hoisted(() => - vi.fn(), -); -const mockLoadTrustedFolders: Mock = vi.hoisted(() => - vi.fn(), -); -const mockDiscover: Mock = - vi.hoisted(() => vi.fn()); +const { + mockInstallOrUpdateExtension, + mockLoadExtensions, + mockExtensionManager, + mockRequestConsentNonInteractive, + mockPromptForConsentNonInteractive, + mockStat, + mockInferInstallMetadata, + mockIsWorkspaceTrusted, + mockLoadTrustedFolders, + mockDiscover, +} = vi.hoisted(() => { + const mockLoadExtensions = vi.fn(); + const mockInstallOrUpdateExtension = vi.fn(); + const mockExtensionManager = vi.fn().mockImplementation(() => ({ + loadExtensions: mockLoadExtensions, + installOrUpdateExtension: mockInstallOrUpdateExtension, + })); + + return { + mockLoadExtensions, + mockInstallOrUpdateExtension, + mockExtensionManager, + mockRequestConsentNonInteractive: vi.fn(), + mockPromptForConsentNonInteractive: vi.fn(), + mockStat: vi.fn(), + mockInferInstallMetadata: vi.fn(), + mockIsWorkspaceTrusted: vi.fn(), + mockLoadTrustedFolders: vi.fn(), + mockDiscover: vi.fn(), + }; +}); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, @@ -82,6 +82,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../config/extension-manager.js') >()), + ExtensionManager: mockExtensionManager, inferInstallMetadata: mockInferInstallMetadata, })); @@ -115,19 +116,18 @@ describe('handleInstall', () => { let processSpy: MockInstance; beforeEach(() => { - debugLogSpy = vi.spyOn(core.debugLogger, 'log'); - debugErrorSpy = vi.spyOn(core.debugLogger, 'error'); + debugLogSpy = vi + .spyOn(core.debugLogger, 'log') + .mockImplementation(() => {}); + debugErrorSpy = vi + .spyOn(core.debugLogger, 'error') + .mockImplementation(() => {}); processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); - vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue( - [], - ); - vi.spyOn( - ExtensionManager.prototype, - 'installOrUpdateExtension', - ).mockImplementation(mockInstallOrUpdateExtension); + mockLoadExtensions.mockResolvedValue([]); + mockInstallOrUpdateExtension.mockReset(); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' }); mockDiscover.mockResolvedValue({ @@ -135,6 +135,7 @@ describe('handleInstall', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], securityWarnings: [], discoveryErrors: [], @@ -160,12 +161,7 @@ describe('handleInstall', () => { }); afterEach(() => { - mockInstallOrUpdateExtension.mockClear(); - mockRequestConsentNonInteractive.mockClear(); - mockStat.mockClear(); - mockInferInstallMetadata.mockClear(); vi.clearAllMocks(); - vi.restoreAllMocks(); }); function createMockExtension( @@ -285,6 +281,39 @@ describe('handleInstall', () => { expect(processSpy).toHaveBeenCalledWith(1); }); + it('should pass promptForSetting when skipSettings is not provided', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: promptForSetting, + }), + ); + }); + + it('should pass null for requestSetting when skipSettings is true', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + skipSettings: true, + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: null, + }), + ); + }); + it('should proceed if local path is already trusted', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ @@ -377,6 +406,7 @@ describe('handleInstall', () => { mcps: [], hooks: [], skills: ['cool-skill'], + agents: ['cool-agent'], settings: [], securityWarnings: ['Security risk!'], discoveryErrors: ['Read error'], @@ -406,6 +436,10 @@ describe('handleInstall', () => { expect.stringContaining('cool-skill'), false, ); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('cool-agent'), + false, + ); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('Security Warnings:'), false, diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 5255dfeb83..cf135a9366 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -5,13 +5,14 @@ */ import type { CommandModule } from 'yargs'; +import * as path from 'node:path'; import chalk from 'chalk'; import { debugLogger, FolderTrustDiscoveryService, getRealPath, + getErrorMessage, } from '@google/gemini-cli-core'; -import { getErrorMessage } from '../../utils/errors.js'; import { INSTALL_WARNING_MESSAGE, promptForConsentNonInteractive, @@ -36,6 +37,7 @@ interface InstallArgs { autoUpdate?: boolean; allowPreRelease?: boolean; consent?: boolean; + skipSettings?: boolean; } export async function handleInstall(args: InstallArgs) { @@ -51,12 +53,13 @@ export async function handleInstall(args: InstallArgs) { const settings = loadSettings(workspaceDir).merged; if (installMetadata.type === 'local' || installMetadata.type === 'link') { - const resolvedPath = getRealPath(source); - installMetadata.source = resolvedPath; - const trustResult = isWorkspaceTrusted(settings, resolvedPath); + const absolutePath = path.resolve(source); + const realPath = getRealPath(absolutePath); + installMetadata.source = absolutePath; + const trustResult = isWorkspaceTrusted(settings, absolutePath); if (trustResult.isTrusted !== true) { const discoveryResults = - await FolderTrustDiscoveryService.discover(resolvedPath); + await FolderTrustDiscoveryService.discover(realPath); const hasDiscovery = discoveryResults.commands.length > 0 || @@ -69,7 +72,7 @@ export async function handleInstall(args: InstallArgs) { '', chalk.bold('Do you trust the files in this folder?'), '', - `The extension source at "${resolvedPath}" is not trusted.`, + `The extension source at "${absolutePath}" is not trusted.`, '', 'Trusting a folder allows Gemini CLI to load its local configurations,', 'including custom commands, hooks, MCP servers, agent skills, and', @@ -97,11 +100,15 @@ export async function handleInstall(args: InstallArgs) { if (hasDiscovery) { promptLines.push(chalk.bold('This folder contains:')); const groups = [ - { label: 'Commands', items: discoveryResults.commands }, - { label: 'MCP Servers', items: discoveryResults.mcps }, - { label: 'Hooks', items: discoveryResults.hooks }, - { label: 'Skills', items: discoveryResults.skills }, - { label: 'Setting overrides', items: discoveryResults.settings }, + { label: 'Commands', items: discoveryResults.commands ?? [] }, + { label: 'MCP Servers', items: discoveryResults.mcps ?? [] }, + { label: 'Hooks', items: discoveryResults.hooks ?? [] }, + { label: 'Skills', items: discoveryResults.skills ?? [] }, + { label: 'Agents', items: discoveryResults.agents ?? [] }, + { + label: 'Setting overrides', + items: discoveryResults.settings ?? [], + }, ].filter((g) => g.items.length > 0); for (const group of groups) { @@ -127,10 +134,10 @@ export async function handleInstall(args: InstallArgs) { ); if (confirmed) { const trustedFolders = loadTrustedFolders(); - await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER); + await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER); } else { throw new Error( - `Installation aborted: Folder "${resolvedPath}" is not trusted.`, + `Installation aborted: Folder "${absolutePath}" is not trusted.`, ); } } @@ -147,7 +154,7 @@ export async function handleInstall(args: InstallArgs) { const extensionManager = new ExtensionManager({ workspaceDir, requestConsent, - requestSetting: promptForSetting, + requestSetting: args.skipSettings ? null : promptForSetting, settings, }); await extensionManager.loadExtensions(); @@ -190,6 +197,11 @@ export const installCommand: CommandModule = { type: 'boolean', default: false, }) + .option('skip-settings', { + describe: 'Skip the configuration on install process.', + type: 'boolean', + default: false, + }) .check((argv) => { if (!argv.source) { throw new Error('The source argument must be provided.'); @@ -208,6 +220,8 @@ export const installCommand: CommandModule = { allowPreRelease: argv['pre-release'] as boolean | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + skipSettings: argv['skip-settings'] as boolean | undefined, }); await exitCli(); }, diff --git a/packages/cli/src/commands/extensions/link.test.ts b/packages/cli/src/commands/extensions/link.test.ts index 67351a5456..d54b81e083 100644 --- a/packages/cli/src/commands/extensions/link.test.ts +++ b/packages/cli/src/commands/extensions/link.test.ts @@ -13,26 +13,24 @@ import { afterEach, type Mock, } from 'vitest'; -import { coreEvents } from '@google/gemini-cli-core'; +import { coreEvents, getErrorMessage } from '@google/gemini-cli-core'; import { type Argv } from 'yargs'; import { handleLink, linkCommand } from './link.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; -import { getErrorMessage } from '../../utils/errors.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const { mockCoreDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); - return mockCoreDebugLogger( - await importOriginal(), - { stripAnsi: true }, - ); + const actual = + await importOriginal(); + const mocked = mockCoreDebugLogger(actual, { stripAnsi: true }); + return { ...mocked, getErrorMessage: vi.fn() }; }); vi.mock('../../config/extension-manager.js'); vi.mock('../../config/settings.js'); -vi.mock('../../utils/errors.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts index d7c5f2fd5c..0f419c5cad 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -8,10 +8,10 @@ import type { CommandModule } from 'yargs'; import chalk from 'chalk'; import { debugLogger, + getErrorMessage, type ExtensionInstallMetadata, } from '@google/gemini-cli-core'; -import { getErrorMessage } from '../../utils/errors.js'; import { INSTALL_WARNING_MESSAGE, requestConsentNonInteractive, diff --git a/packages/cli/src/commands/extensions/list.test.ts b/packages/cli/src/commands/extensions/list.test.ts index f0f0168f79..b65cfdaf3e 100644 --- a/packages/cli/src/commands/extensions/list.test.ts +++ b/packages/cli/src/commands/extensions/list.test.ts @@ -5,27 +5,23 @@ */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { coreEvents } from '@google/gemini-cli-core'; +import { coreEvents, getErrorMessage } from '@google/gemini-cli-core'; import { handleList, listCommand } from './list.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; -import { getErrorMessage } from '../../utils/errors.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const { mockCoreDebugLogger } = await import( '../../test-utils/mockDebugLogger.js' ); - return mockCoreDebugLogger( - await importOriginal(), - { - stripAnsi: false, - }, - ); + const actual = + await importOriginal(); + const mocked = mockCoreDebugLogger(actual, { stripAnsi: false }); + return { ...mocked, getErrorMessage: vi.fn() }; }); vi.mock('../../config/extension-manager.js'); vi.mock('../../config/settings.js'); -vi.mock('../../utils/errors.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 9b4789ca55..e477ce3c21 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -5,8 +5,7 @@ */ import type { CommandModule } from 'yargs'; -import { getErrorMessage } from '../../utils/errors.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { loadSettings } from '../../config/settings.js'; diff --git a/packages/cli/src/commands/extensions/uninstall.test.ts b/packages/cli/src/commands/extensions/uninstall.test.ts index 8ae9f6d376..341c0f7a7e 100644 --- a/packages/cli/src/commands/extensions/uninstall.test.ts +++ b/packages/cli/src/commands/extensions/uninstall.test.ts @@ -18,7 +18,7 @@ import { type Argv } from 'yargs'; import { handleUninstall, uninstallCommand } from './uninstall.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; -import { getErrorMessage } from '../../utils/errors.js'; +import { getErrorMessage } from '@google/gemini-cli-core'; // NOTE: This file uses vi.hoisted() mocks to enable testing of sequential // mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining). @@ -28,6 +28,7 @@ import { getErrorMessage } from '../../utils/errors.js'; // Hoisted mocks - these survive vi.clearAllMocks() const mockUninstallExtension = vi.hoisted(() => vi.fn()); const mockLoadExtensions = vi.hoisted(() => vi.fn()); +const mockGetExtensions = vi.hoisted(() => vi.fn()); // Mock dependencies with hoisted functions vi.mock('../../config/extension-manager.js', async (importOriginal) => { @@ -38,6 +39,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => { ExtensionManager: vi.fn().mockImplementation(() => ({ uninstallExtension: mockUninstallExtension, loadExtensions: mockLoadExtensions, + getExtensions: mockGetExtensions, setRequestConsent: vi.fn(), setRequestSetting: vi.fn(), })), @@ -64,11 +66,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { emitConsoleLog, }, debugLogger, + getErrorMessage: vi.fn(), }; }); vi.mock('../../config/settings.js'); -vi.mock('../../utils/errors.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); @@ -93,6 +95,7 @@ describe('extensions uninstall command', () => { afterEach(() => { mockLoadExtensions.mockClear(); mockUninstallExtension.mockClear(); + mockGetExtensions.mockClear(); vi.clearAllMocks(); }); @@ -145,6 +148,41 @@ describe('extensions uninstall command', () => { mockCwd.mockRestore(); }); + it('should uninstall all extensions when --all flag is used', async () => { + mockLoadExtensions.mockResolvedValue(undefined); + mockUninstallExtension.mockResolvedValue(undefined); + mockGetExtensions.mockReturnValue([{ name: 'ext1' }, { name: 'ext2' }]); + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + await handleUninstall({ all: true }); + + expect(mockUninstallExtension).toHaveBeenCalledTimes(2); + expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false); + expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "ext1" successfully uninstalled.', + ); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'Extension "ext2" successfully uninstalled.', + ); + mockCwd.mockRestore(); + }); + + it('should log a message if no extensions are installed and --all flag is used', async () => { + mockLoadExtensions.mockResolvedValue(undefined); + mockGetExtensions.mockReturnValue([]); + const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); + await handleUninstall({ all: true }); + + expect(mockUninstallExtension).not.toHaveBeenCalled(); + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', + 'No extensions currently installed.', + ); + mockCwd.mockRestore(); + }); + it('should report errors for failed uninstalls but continue with others', async () => { mockLoadExtensions.mockResolvedValue(undefined); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); @@ -236,13 +274,14 @@ describe('extensions uninstall command', () => { const command = uninstallCommand; it('should have correct command and describe', () => { - expect(command.command).toBe('uninstall '); + expect(command.command).toBe('uninstall [names..]'); expect(command.describe).toBe('Uninstalls one or more extensions.'); }); describe('builder', () => { interface MockYargs { positional: Mock; + option: Mock; check: Mock; } @@ -250,11 +289,12 @@ describe('extensions uninstall command', () => { beforeEach(() => { yargsMock = { positional: vi.fn().mockReturnThis(), + option: vi.fn().mockReturnThis(), check: vi.fn().mockReturnThis(), }; }); - it('should configure positional argument', () => { + it('should configure arguments and options', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); @@ -264,18 +304,31 @@ describe('extensions uninstall command', () => { type: 'string', array: true, }); + expect(yargsMock.option).toHaveBeenCalledWith('all', { + type: 'boolean', + describe: 'Uninstall all installed extensions.', + default: false, + }); expect(yargsMock.check).toHaveBeenCalled(); }); - it('check function should throw for missing names', () => { + it('check function should throw for missing names and no --all flag', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); const checkCallback = yargsMock.check.mock.calls[0][0]; - expect(() => checkCallback({ names: [] })).toThrow( - 'Please include at least one extension name to uninstall as a positional argument.', + expect(() => checkCallback({ names: [], all: false })).toThrow( + 'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.', ); }); + + it('check function should pass if --all flag is used even without names', () => { + (command.builder as (yargs: Argv) => Argv)( + yargsMock as unknown as Argv, + ); + const checkCallback = yargsMock.check.mock.calls[0][0]; + expect(() => checkCallback({ names: [], all: true })).not.toThrow(); + }); }); it('handler should call handleUninstall', async () => { @@ -283,10 +336,17 @@ describe('extensions uninstall command', () => { mockUninstallExtension.mockResolvedValue(undefined); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); interface TestArgv { - names: string[]; - [key: string]: unknown; + names?: string[]; + all?: boolean; + _: string[]; + $0: string; } - const argv: TestArgv = { names: ['my-extension'], _: [], $0: '' }; + const argv: TestArgv = { + names: ['my-extension'], + all: false, + _: [], + $0: '', + }; await (command.handler as unknown as (args: TestArgv) => Promise)( argv, ); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index a67a4d3abe..3a63602149 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -5,8 +5,7 @@ */ import type { CommandModule } from 'yargs'; -import { getErrorMessage } from '../../utils/errors.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings } from '../../config/settings.js'; @@ -14,7 +13,8 @@ import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; interface UninstallArgs { - names: string[]; // can be extension names or source URLs. + names?: string[]; // can be extension names or source URLs. + all?: boolean; } export async function handleUninstall(args: UninstallArgs) { @@ -28,8 +28,24 @@ export async function handleUninstall(args: UninstallArgs) { }); await extensionManager.loadExtensions(); + let namesToUninstall: string[] = []; + if (args.all) { + namesToUninstall = extensionManager + .getExtensions() + .map((ext) => ext.name); + } else if (args.names) { + namesToUninstall = [...new Set(args.names)]; + } + + if (namesToUninstall.length === 0) { + if (args.all) { + debugLogger.log('No extensions currently installed.'); + } + return; + } + const errors: Array<{ name: string; error: string }> = []; - for (const name of [...new Set(args.names)]) { + for (const name of namesToUninstall) { try { await extensionManager.uninstallExtension(name, false); debugLogger.log(`Extension "${name}" successfully uninstalled.`); @@ -51,7 +67,7 @@ export async function handleUninstall(args: UninstallArgs) { } export const uninstallCommand: CommandModule = { - command: 'uninstall ', + command: 'uninstall [names..]', describe: 'Uninstalls one or more extensions.', builder: (yargs) => yargs @@ -61,10 +77,15 @@ export const uninstallCommand: CommandModule = { type: 'string', array: true, }) + .option('all', { + type: 'boolean', + describe: 'Uninstall all installed extensions.', + default: false, + }) .check((argv) => { - if (!argv.names || argv.names.length === 0) { + if (!argv.all && (!argv.names || argv.names.length === 0)) { throw new Error( - 'Please include at least one extension name to uninstall as a positional argument.', + 'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.', ); } return true; @@ -72,7 +93,9 @@ export const uninstallCommand: CommandModule = { handler: async (argv) => { await handleUninstall({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - names: argv['names'] as string[], + names: argv['names'] as string[] | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + all: argv['all'] as boolean, }); await exitCli(); }, diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 4e5f593518..2459b5d7c4 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -12,9 +12,12 @@ import { updateExtension, } from '../../config/extensions/update.js'; import { checkForExtensionUpdate } from '../../config/extensions/github.js'; -import { getErrorMessage } from '../../utils/errors.js'; import { ExtensionUpdateState } from '../../ui/state/extensions.js'; -import { coreEvents, debugLogger } from '@google/gemini-cli-core'; +import { + coreEvents, + debugLogger, + getErrorMessage, +} from '@google/gemini-cli-core'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { loadSettings } from '../../config/settings.js'; diff --git a/packages/cli/src/commands/extensions/validate.ts b/packages/cli/src/commands/extensions/validate.ts index 1385871219..e122b279dc 100644 --- a/packages/cli/src/commands/extensions/validate.ts +++ b/packages/cli/src/commands/extensions/validate.ts @@ -5,11 +5,10 @@ */ import type { CommandModule } from 'yargs'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import semver from 'semver'; -import { getErrorMessage } from '../../utils/errors.js'; import type { ExtensionConfig } from '../../config/extension.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts index 47cc8660d7..36bb2cf9aa 100644 --- a/packages/cli/src/commands/hooks/migrate.ts +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -79,6 +79,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown { migrated['command'] = hook['command']; // Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command + // eslint-disable-next-line no-restricted-syntax if (typeof migrated['command'] === 'string') { migrated['command'] = migrated['command'].replace( /\$CLAUDE_PROJECT_DIR/g, @@ -93,6 +94,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown { } // Map timeout field (Claude uses seconds, Gemini uses seconds) + // eslint-disable-next-line no-restricted-syntax if ('timeout' in hook && typeof hook['timeout'] === 'number') { migrated['timeout'] = hook['timeout']; } @@ -140,6 +142,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record { // Transform matcher if ( 'matcher' in definition && + // eslint-disable-next-line no-restricted-syntax typeof definition['matcher'] === 'string' ) { migratedDef['matcher'] = transformMatcher(definition['matcher']); diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index 2877f84714..715786859b 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -6,8 +6,7 @@ import { describe, it, expect, vi } from 'vitest'; import { mcpCommand } from './mcp.js'; -import { type Argv } from 'yargs'; -import yargs from 'yargs'; +import yargs, { type Argv } from 'yargs'; describe('mcp command', () => { it('should have correct command definition', () => { diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 60912c51f5..578894845e 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -4,13 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import { listMcpServers } from './list.js'; -import { loadSettings, mergeSettings } from '../../config/settings.js'; +import { + loadSettings, + mergeSettings, + type LoadedSettings, +} from '../../config/settings.js'; import { createTransport, debugLogger } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionStorage } from '../../config/extensions/storage.js'; import { ExtensionManager } from '../../config/extension-manager.js'; +import { McpServerEnablementManager } from '../../config/mcp/index.js'; vi.mock('../../config/settings.js', async (importOriginal) => { const actual = @@ -37,6 +50,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { CONNECTED: 'CONNECTED', CONNECTING: 'CONNECTING', DISCONNECTED: 'DISCONNECTED', + BLOCKED: 'BLOCKED', + DISABLED: 'DISABLED', }, Storage: Object.assign( vi.fn().mockImplementation((_cwd: string) => ({ @@ -46,6 +61,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { })), { getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + getGlobalGeminiDir: () => '/tmp/gemini', }, ), GEMINI_DIR: '.gemini', @@ -88,6 +104,12 @@ describe('mcp list command', () => { beforeEach(() => { vi.resetAllMocks(); vi.spyOn(debugLogger, 'log').mockImplementation(() => {}); + McpServerEnablementManager.resetInstance(); + // Use a mock for isFileEnabled to avoid reading real files + vi.spyOn( + McpServerEnablementManager.prototype, + 'isFileEnabled', + ).mockResolvedValue(true); mockTransport = { close: vi.fn() }; mockClient = { @@ -106,6 +128,10 @@ describe('mcp list command', () => { mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir'); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should display message when no servers configured', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ @@ -133,6 +159,7 @@ describe('mcp list command', () => { }, }, }, + isTrusted: true, }); mockClient.connect.mockResolvedValue(undefined); @@ -199,6 +226,7 @@ describe('mcp list command', () => { 'config-server': { command: '/config/server' }, }, }, + isTrusted: true, }); mockExtensionManager.loadExtensions.mockReturnValue([ @@ -236,6 +264,7 @@ describe('mcp list command', () => { config: { 'allowed-server': { url: 'http://allowed' }, }, + requiredConfig: {}, }, }; @@ -251,7 +280,10 @@ describe('mcp list command', () => { mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); - await listMcpServers(settingsWithAllowlist); + await listMcpServers({ + merged: settingsWithAllowlist, + isTrusted: true, + } as unknown as LoadedSettings); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('allowed-server'), @@ -266,4 +298,80 @@ describe('mcp list command', () => { expect.anything(), ); }); + + it('should show stdio servers as disconnected in untrusted folders', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { + ...defaultMergedSettings, + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + }, + isTrusted: false, + }); + + // createTransport will throw in core if not trusted + mockedCreateTransport.mockRejectedValue(new Error('Folder not trusted')); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'test-server: /test/server (stdio) - Disconnected', + ), + ); + }); + + it('should display blocked status for servers in excluded list', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { + ...defaultMergedSettings, + mcp: { + excluded: ['blocked-server'], + }, + mcpServers: { + 'blocked-server': { command: '/test/server' }, + }, + }, + isTrusted: true, + }); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'blocked-server: /test/server (stdio) - Blocked', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); + + it('should display disabled status for servers disabled via enablement manager', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { + ...defaultMergedSettings, + mcpServers: { + 'disabled-server': { command: '/test/server' }, + }, + }, + isTrusted: true, + }); + + vi.spyOn( + McpServerEnablementManager.prototype, + 'isFileEnabled', + ).mockResolvedValue(false); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'disabled-server: /test/server (stdio) - Disabled', + ), + ); + expect(mockedCreateTransport).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index d51093fbfa..8154e3b7bf 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -6,8 +6,11 @@ // File for 'gemini mcp list' command import type { CommandModule } from 'yargs'; -import { type MergedSettings, loadSettings } from '../../config/settings.js'; -import type { MCPServerConfig } from '@google/gemini-cli-core'; +import { + type MergedSettings, + loadSettings, + type LoadedSettings, +} from '../../config/settings.js'; import { MCPServerStatus, createTransport, @@ -15,16 +18,17 @@ import { applyAdminAllowlist, getAdminBlockedMcpServersMessage, } from '@google/gemini-cli-core'; +import type { MCPServerConfig } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionManager } from '../../config/extension-manager.js'; +import { + canLoadServer, + McpServerEnablementManager, +} from '../../config/mcp/index.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; - -const COLOR_GREEN = '\u001b[32m'; -const COLOR_YELLOW = '\u001b[33m'; -const COLOR_RED = '\u001b[31m'; -const RESET_COLOR = '\u001b[0m'; +import chalk from 'chalk'; export async function getMcpServersFromConfig( settings?: MergedSettings, @@ -50,6 +54,7 @@ export async function getMcpServersFromConfig( return; } mcpServers[key] = { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...server, extension, }; @@ -65,28 +70,57 @@ export async function getMcpServersFromConfig( async function testMCPConnection( serverName: string, config: MCPServerConfig, + isTrusted: boolean, + activeSettings: MergedSettings, ): Promise { + // SECURITY: Only test connection if workspace is trusted or if it's a remote server. + // stdio servers execute local commands and must never run in untrusted workspaces. + const isStdio = !!config.command; + if (isStdio && !isTrusted) { + return MCPServerStatus.DISCONNECTED; + } + const client = new Client({ name: 'mcp-test-client', version: '0.0.1', }); - const settings = loadSettings(); - const sanitizationConfig = { - enableEnvironmentVariableRedaction: true, - allowedEnvironmentVariables: [], - blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, + const mcpContext = { + sanitizationConfig: { + enableEnvironmentVariableRedaction: true, + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: activeSettings.advanced.excludedEnvVars, + }, + emitMcpDiagnostic: ( + severity: 'info' | 'warning' | 'error', + message: string, + error?: unknown, + serverName?: string, + ) => { + // In non-interactive list, we log everything through debugLogger for consistency + if (severity === 'error') { + debugLogger.error( + chalk.red(`Error${serverName ? ` (${serverName})` : ''}: ${message}`), + error, + ); + } else if (severity === 'warning') { + debugLogger.warn( + chalk.yellow( + `Warning${serverName ? ` (${serverName})` : ''}: ${message}`, + ), + error, + ); + } else { + debugLogger.log(message, error); + } + }, + isTrustedFolder: () => isTrusted, }; let transport; try { // Use the same transport creation logic as core - transport = await createTransport( - serverName, - config, - false, - sanitizationConfig, - ); + transport = await createTransport(serverName, config, false, mcpContext); } catch (_error) { await client.close(); return MCPServerStatus.DISCONNECTED; @@ -110,14 +144,40 @@ async function testMCPConnection( async function getServerStatus( serverName: string, server: MCPServerConfig, + isTrusted: boolean, + activeSettings: MergedSettings, ): Promise { + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const loadResult = await canLoadServer(serverName, { + adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true, + allowedList: activeSettings.mcp?.allowed, + excludedList: activeSettings.mcp?.excluded, + enablement: mcpEnablementManager.getEnablementCallbacks(), + }); + + if (!loadResult.allowed) { + if ( + loadResult.blockType === 'admin' || + loadResult.blockType === 'allowlist' || + loadResult.blockType === 'excludelist' + ) { + return MCPServerStatus.BLOCKED; + } + return MCPServerStatus.DISABLED; + } + // Test all server types by attempting actual connection - return testMCPConnection(serverName, server); + return testMCPConnection(serverName, server, isTrusted, activeSettings); } -export async function listMcpServers(settings?: MergedSettings): Promise { +export async function listMcpServers( + loadedSettingsArg?: LoadedSettings, +): Promise { + const loadedSettings = loadedSettingsArg ?? loadSettings(); + const activeSettings = loadedSettings.merged; + const { mcpServers, blockedServerNames } = - await getMcpServersFromConfig(settings); + await getMcpServersFromConfig(activeSettings); const serverNames = Object.keys(mcpServers); if (blockedServerNames.length > 0) { @@ -125,7 +185,7 @@ export async function listMcpServers(settings?: MergedSettings): Promise { blockedServerNames, undefined, ); - debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n'); + debugLogger.log(chalk.yellow(message + '\n')); } if (serverNames.length === 0) { @@ -140,22 +200,35 @@ export async function listMcpServers(settings?: MergedSettings): Promise { for (const serverName of serverNames) { const server = mcpServers[serverName]; - const status = await getServerStatus(serverName, server); + const status = await getServerStatus( + serverName, + server, + loadedSettings.isTrusted, + activeSettings, + ); let statusIndicator = ''; let statusText = ''; switch (status) { case MCPServerStatus.CONNECTED: - statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR; + statusIndicator = chalk.green('✓'); statusText = 'Connected'; break; case MCPServerStatus.CONNECTING: - statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR; + statusIndicator = chalk.yellow('…'); statusText = 'Connecting'; break; + case MCPServerStatus.BLOCKED: + statusIndicator = chalk.red('⛔'); + statusText = 'Blocked'; + break; + case MCPServerStatus.DISABLED: + statusIndicator = chalk.gray('○'); + statusText = 'Disabled'; + break; case MCPServerStatus.DISCONNECTED: default: - statusIndicator = COLOR_RED + '✗' + RESET_COLOR; + statusIndicator = chalk.red('✗'); statusText = 'Disconnected'; break; } @@ -178,14 +251,14 @@ export async function listMcpServers(settings?: MergedSettings): Promise { } interface ListArgs { - settings?: MergedSettings; + loadedSettings?: LoadedSettings; } export const listCommand: CommandModule = { command: 'list', describe: 'List all configured MCP servers', handler: async (argv) => { - await listMcpServers(argv.settings); + await listMcpServers(argv.loadedSettings); await exitCli(); }, }; diff --git a/packages/cli/src/commands/skills/install.test.ts b/packages/cli/src/commands/skills/install.test.ts index faaa7f31c6..db2548950d 100644 --- a/packages/cli/src/commands/skills/install.test.ts +++ b/packages/cli/src/commands/skills/install.test.ts @@ -28,6 +28,9 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => { vi.mock('@google/gemini-cli-core', () => ({ debugLogger, + getErrorMessage: vi.fn((e: unknown) => + e instanceof Error ? e.message : String(e), + ), })); import { handleInstall, installCommand } from './install.js'; diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts index 70ee094ae5..75dad58f0f 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -5,8 +5,11 @@ */ import type { CommandModule } from 'yargs'; -import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core'; -import { getErrorMessage } from '../../utils/errors.js'; +import { + debugLogger, + type SkillDefinition, + getErrorMessage, +} from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; import { installSkill } from '../../utils/skillUtils.js'; import chalk from 'chalk'; diff --git a/packages/cli/src/commands/skills/link.test.ts b/packages/cli/src/commands/skills/link.test.ts index 24c3d3ff64..e661440952 100644 --- a/packages/cli/src/commands/skills/link.test.ts +++ b/packages/cli/src/commands/skills/link.test.ts @@ -24,6 +24,9 @@ const { debugLogger } = await vi.hoisted(async () => { vi.mock('@google/gemini-cli-core', () => ({ debugLogger, + getErrorMessage: vi.fn((e: unknown) => + e instanceof Error ? e.message : String(e), + ), })); vi.mock('../../config/extensions/consent.js', () => ({ diff --git a/packages/cli/src/commands/skills/link.ts b/packages/cli/src/commands/skills/link.ts index 60bf364bf4..3a03b93e6b 100644 --- a/packages/cli/src/commands/skills/link.ts +++ b/packages/cli/src/commands/skills/link.ts @@ -5,10 +5,9 @@ */ import type { CommandModule } from 'yargs'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import chalk from 'chalk'; -import { getErrorMessage } from '../../utils/errors.js'; import { exitCli } from '../utils.js'; import { requestConsentNonInteractive, diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts index c330af75ba..391749242b 100644 --- a/packages/cli/src/commands/skills/list.test.ts +++ b/packages/cli/src/commands/skills/list.test.ts @@ -5,11 +5,10 @@ */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { coreEvents } from '@google/gemini-cli-core'; +import { coreEvents, type Config } from '@google/gemini-cli-core'; import { handleList, listCommand } from './list.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import { loadCliConfig } from '../../config/config.js'; -import type { Config } from '@google/gemini-cli-core'; import chalk from 'chalk'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { diff --git a/packages/cli/src/commands/skills/uninstall.test.ts b/packages/cli/src/commands/skills/uninstall.test.ts index ab51db5b53..e12bda5353 100644 --- a/packages/cli/src/commands/skills/uninstall.test.ts +++ b/packages/cli/src/commands/skills/uninstall.test.ts @@ -21,6 +21,9 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => { vi.mock('@google/gemini-cli-core', () => ({ debugLogger, + getErrorMessage: vi.fn((e: unknown) => + e instanceof Error ? e.message : String(e), + ), })); import { handleUninstall, uninstallCommand } from './uninstall.js'; diff --git a/packages/cli/src/commands/skills/uninstall.ts b/packages/cli/src/commands/skills/uninstall.ts index d5f030e1d2..cfcb67da21 100644 --- a/packages/cli/src/commands/skills/uninstall.ts +++ b/packages/cli/src/commands/skills/uninstall.ts @@ -5,8 +5,7 @@ */ import type { CommandModule } from 'yargs'; -import { debugLogger } from '@google/gemini-cli-core'; -import { getErrorMessage } from '../../utils/errors.js'; +import { debugLogger, getErrorMessage } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; import { uninstallSkill } from '../../utils/skillUtils.js'; import chalk from 'chalk'; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 75812e4442..2325711ad0 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -19,6 +19,8 @@ import { debugLogger, ApprovalMode, type MCPServerConfig, + type GeminiCLIExtension, + Storage, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import { @@ -114,14 +116,16 @@ vi.mock('@google/gemini-cli-core', async () => { ( cwd, dirs, - debug, fileService, extensionLoader: ExtensionLoader, + _folderTrust, + _importFormat, + _fileFilteringOptions, _maxDirs, ) => { - const extensionPaths = extensionLoader - .getExtensions() - .flatMap((e) => e.contextFiles); + const extensionPaths = + extensionLoader?.getExtensions?.()?.flatMap((e) => e.contextFiles) || + []; return Promise.resolve({ memoryContent: extensionPaths.join(',') || '', fileCount: extensionPaths?.length || 0, @@ -222,6 +226,51 @@ afterEach(() => { }); describe('parseArguments', () => { + describe('worktree', () => { + it('should parse --worktree flag when provided with a name', async () => { + process.argv = ['node', 'script.js', '--worktree', 'my-feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBe('my-feature'); + }); + + it('should generate a random name when --worktree is provided without a name', async () => { + process.argv = ['node', 'script.js', '--worktree']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBeDefined(); + expect(argv.worktree).not.toBe(''); + expect(typeof argv.worktree).toBe('string'); + }); + + it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => { + process.argv = ['node', 'script.js', '--worktree', 'feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = false; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(settings)).rejects.toThrow( + 'process.exit called', + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.', + ), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + }); + it.each([ { description: 'long flags', @@ -759,6 +808,48 @@ describe('loadCliConfig', () => { }); }); + it('should add IDE workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH to include directories', async () => { + vi.stubEnv( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + ['/project/folderA', '/project/folderB'].join(path.delimiter), + ); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + const dirs = config.getPendingIncludeDirectories(); + expect(dirs).toContain('/project/folderA'); + expect(dirs).toContain('/project/folderB'); + }); + + it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => { + const resolveToRealPathSpy = vi + .spyOn(ServerConfig, 'resolveToRealPath') + .mockImplementation((p) => { + if (p.toString().includes('restricted')) { + const err = new Error('EACCES: permission denied'); + (err as NodeJS.ErrnoException).code = 'EACCES'; + throw err; + } + return p.toString(); + }); + vi.stubEnv( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + ['/project/folderA', '/nonexistent/restricted/folder'].join( + path.delimiter, + ), + ); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + const dirs = config.getPendingIncludeDirectories(); + expect(dirs).toContain('/project/folderA'); + expect(dirs).not.toContain('/nonexistent/restricted/folder'); + + resolveToRealPathSpy.mockRestore(); + }); + it('should use default fileFilter options when unconfigured', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); @@ -794,6 +885,7 @@ describe('loadCliConfig', () => { describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { beforeEach(() => { vi.resetAllMocks(); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', ''); // Restore ExtensionManager mocks that were reset ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); ExtensionManager.prototype.loadExtensions = vi @@ -805,12 +897,15 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should pass extension context file paths to loadServerHierarchicalMemory', async () => { process.argv = ['node', 'script.js']; - const settings = createTestMergedSettings(); + const settings = createTestMergedSettings({ + experimental: { jitContext: false }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -845,7 +940,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], - false, expect.any(Object), expect.any(ExtensionManager), true, @@ -862,6 +956,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { process.argv = ['node', 'script.js']; const includeDir = path.resolve(path.sep, 'path', 'to', 'include'); const settings = createTestMergedSettings({ + experimental: { jitContext: false }, context: { includeDirectories: [includeDir], loadMemoryFromIncludeDirectories: true, @@ -874,7 +969,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [includeDir], - false, expect.any(Object), expect.any(ExtensionManager), true, @@ -890,6 +984,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ + experimental: { jitContext: false }, context: { includeDirectories: ['/path/to/include'], loadMemoryFromIncludeDirectories: false, @@ -902,7 +997,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], - false, expect.any(Object), expect.any(ExtensionManager), true, @@ -951,12 +1045,6 @@ describe('mergeMcpServers', () => { }); describe('mergeExcludeTools', () => { - const defaultExcludes = new Set([ - SHELL_TOOL_NAME, - EDIT_TOOL_NAME, - WRITE_FILE_TOOL_NAME, - WEB_FETCH_TOOL_NAME, - ]); const originalIsTTY = process.stdin.isTTY; beforeEach(() => { @@ -1078,9 +1166,7 @@ describe('mergeExcludeTools', () => { process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getExcludeTools()).toEqual( - new Set([...defaultExcludes, ASK_USER_TOOL_NAME]), - ); + expect(config.getExcludeTools()).toEqual(new Set([ASK_USER_TOOL_NAME])); }); it('should handle settings with excludeTools but no extensions', async () => { @@ -1161,9 +1247,9 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); - expect(excludedTools).toContain(SHELL_TOOL_NAME); - expect(excludedTools).toContain(EDIT_TOOL_NAME); - expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); @@ -1182,9 +1268,9 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); - expect(excludedTools).toContain(SHELL_TOOL_NAME); - expect(excludedTools).toContain(EDIT_TOOL_NAME); - expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); @@ -1203,7 +1289,7 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); - expect(excludedTools).toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); expect(excludedTools).not.toContain(EDIT_TOOL_NAME); expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); @@ -1249,9 +1335,9 @@ describe('Approval mode tool exclusion logic', () => { const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); - expect(excludedTools).toContain(SHELL_TOOL_NAME); - expect(excludedTools).toContain(EDIT_TOOL_NAME); - expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME); + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); + expect(excludedTools).not.toContain(EDIT_TOOL_NAME); + expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); @@ -1313,9 +1399,10 @@ describe('Approval mode tool exclusion logic', () => { const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain('custom_tool'); // From settings - expect(excludedTools).toContain(SHELL_TOOL_NAME); // From approval mode + expect(excludedTools).not.toContain(SHELL_TOOL_NAME); // No longer from approval mode expect(excludedTools).not.toContain(EDIT_TOOL_NAME); // Should be allowed in auto_edit expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); // Should be allowed in auto_edit + expect(excludedTools).toContain(ASK_USER_TOOL_NAME); }); it('should throw an error if YOLO mode is attempted when disableYoloMode is true', async () => { @@ -1629,6 +1716,7 @@ describe('loadCliConfig with admin.mcp.config', () => { const serverA = config.getMcpServers()?.['serverA']; expect(serverA).toEqual({ + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...localMcpServers['serverA'], type: 'sse', url: 'https://admin-server-a.com/sse', @@ -1679,6 +1767,7 @@ describe('loadCliConfig with admin.mcp.config', () => { }; const localMcpServersWithTools: Record = { serverA: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...localMcpServers['serverA'], includeTools: ['local_tool'], timeout: 1234, @@ -1721,6 +1810,7 @@ describe('loadCliConfig with admin.mcp.config', () => { }; const localMcpServersWithTools: Record = { serverA: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...localMcpServers['serverA'], includeTools: ['local_tool'], }, @@ -2162,9 +2252,9 @@ describe('loadCliConfig tool exclusions', () => { 'test-session', argv, ); - expect(config.getExcludeTools()).toContain('run_shell_command'); - expect(config.getExcludeTools()).toContain('replace'); - expect(config.getExcludeTools()).toContain('write_file'); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); expect(config.getExcludeTools()).toContain('ask_user'); }); @@ -2183,6 +2273,30 @@ describe('loadCliConfig tool exclusions', () => { expect(config.getExcludeTools()).toContain('ask_user'); }); + it('should exclude ask_user in interactive mode when --acp is provided', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--acp']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + + it('should exclude ask_user in interactive mode when --experimental-acp is provided', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--experimental-acp']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => { process.stdin.isTTY = false; process.argv = [ @@ -2202,7 +2316,7 @@ describe('loadCliConfig tool exclusions', () => { expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); - it('should exclude web-fetch in non-interactive mode when not allowed', async () => { + it('should not exclude web-fetch in non-interactive mode at config level', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); @@ -2211,7 +2325,7 @@ describe('loadCliConfig tool exclusions', () => { 'test-session', argv, ); - expect(config.getExcludeTools()).toContain(WEB_FETCH_TOOL_NAME); + expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME); }); it('should not exclude web-fetch in non-interactive mode when allowed', async () => { @@ -2628,13 +2742,13 @@ describe('loadCliConfig approval mode', () => { expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); - it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => { + it('should allow plan approval mode by default when --approval-mode=plan is used', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'plan']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({}); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN); }); it('should pass planSettings.directory from settings to config', async () => { @@ -2765,6 +2879,66 @@ describe('loadCliConfig approval mode', () => { }); }); +describe('loadCliConfig gemmaModelRouter', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should have gemmaModelRouter disabled by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(false); + }); + + it('should load gemmaModelRouter settings from merged settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + gemmaModelRouter: { + enabled: true, + classifier: { + host: 'http://custom:1234', + model: 'custom-gemma', + }, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(true); + const gemmaSettings = config.getGemmaModelRouterSettings(); + expect(gemmaSettings.classifier?.host).toBe('http://custom:1234'); + expect(gemmaSettings.classifier?.model).toBe('custom-gemma'); + }); + + it('should handle partial gemmaModelRouter settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + gemmaModelRouter: { + enabled: true, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(true); + const gemmaSettings = config.getGemmaModelRouterSettings(); + expect(gemmaSettings.classifier?.host).toBe('http://localhost:9379'); + expect(gemmaSettings.classifier?.model).toBe('gemma3-1b-gpu-custom'); + }); +}); + describe('loadCliConfig fileFiltering', () => { const originalArgv = process.argv; @@ -3264,11 +3438,11 @@ describe('Policy Engine Integration in loadCliConfig', () => { await loadCliConfig(settings, 'test-session', argv); - // In non-interactive mode, ShellTool, etc. are excluded + // In non-interactive mode, only ask_user is excluded by default expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ - exclude: expect.arrayContaining([SHELL_TOOL_NAME]), + exclude: expect.arrayContaining([ASK_USER_TOOL_NAME]), }), }), expect.anything(), @@ -3289,7 +3463,10 @@ describe('Policy Engine Integration in loadCliConfig', () => { expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ - policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'], + policyPaths: [ + path.normalize('/path/to/policy1.toml'), + path.normalize('/path/to/policy2.toml'), + ], }), expect.anything(), ); @@ -3464,4 +3641,156 @@ describe('loadCliConfig mcpEnabled', () => { expect(config.getAllowedMcpServers()).toEqual(['serverA']); expect(config.getBlockedMcpServers()).toEqual(['serverB']); }); + + describe('extension plan settings', () => { + beforeEach(() => { + vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue( + '/mock/home/user/.gemini/tmp/test-project', + ); + }); + + it('should use plan directory from active extension when user has not specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from active extension when user has specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + general: { + plan: { directory: 'user-plans-dir' }, + }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('user-plans-dir'); + expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from inactive extension', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: false, + plan: { directory: 'ext-plans-dir-inactive' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).not.toContain( + 'ext-plans-dir-inactive', + ); + }); + + it('should use default path if neither user nor extension settings provide a plan directory', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + // No extensions providing plan directory + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + + const config = await loadCliConfig(settings, 'test-session', argv); + // Should return the default managed temp directory path + expect(config.storage.getPlansDir()).toBe( + path.join( + '/mock', + 'home', + 'user', + '.gemini', + 'tmp', + 'test-project', + 'test-session', + 'plans', + ), + ); + }); + }); +}); + +describe('loadCliConfig acpMode and clientName', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should set acpMode to true and detect clientName when --acp flag is used', async () => { + process.argv = ['node', 'script.js', '--acp']; + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', ''); + vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', ''); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getAcpMode()).toBe(true); + expect(config.getClientName()).toBe('acp-vscode'); + }); + + it('should set acpMode to true but leave clientName undefined for generic terminals', async () => { + process.argv = ['node', 'script.js', '--acp']; + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal + vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', ''); + vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', ''); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getAcpMode()).toBe(true); + expect(config.getClientName()).toBeUndefined(); + }); + + it('should set acpMode to false and clientName to undefined by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getAcpMode()).toBe(false); + expect(config.getClientName()).toBeUndefined(); + }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 80868ba50b..1f72e0e126 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -4,9 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import yargs from 'yargs/yargs'; +import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; +import * as path from 'node:path'; +import { execa } from 'execa'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; @@ -19,16 +21,11 @@ import { DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, FileDiscoveryService, - WRITE_FILE_TOOL_NAME, - SHELL_TOOL_NAMES, - SHELL_TOOL_NAME, resolveTelemetrySettings, FatalConfigError, getPty, - EDIT_TOOL_NAME, debugLogger, loadServerHierarchicalMemory, - WEB_FETCH_TOOL_NAME, ASK_USER_TOOL_NAME, getVersion, PREVIEW_GEMINI_MODEL_AUTO, @@ -38,17 +35,25 @@ import { getAdminErrorMessage, isHeadlessMode, Config, + resolveToRealPath, applyAdminAllowlist, + applyRequiredServers, getAdminBlockedMcpServersMessage, + getProjectRootForWorktree, + isGeminiWorktree, + type WorktreeSettings, type HookDefinition, type HookEventName, type OutputFormat, + detectIdeFromEnv, } from '@google/gemini-cli-core'; import { type Settings, type MergedSettings, saveModelChange, loadSettings, + isWorktreeEnabled, + type LoadedSettings, } from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; @@ -75,13 +80,16 @@ export interface CliArgs { debug: boolean | undefined; prompt: string | undefined; promptInteractive: string | undefined; + worktree?: string; yolo: boolean | undefined; approvalMode: string | undefined; policy: string[] | undefined; + adminPolicy: string[] | undefined; allowedMcpServerNames: string[] | undefined; allowedTools: string[] | undefined; - experimentalAcp: boolean | undefined; + acp?: boolean; + experimentalAcp?: boolean; extensions: string[] | undefined; listExtensions: boolean | undefined; resume: string | typeof RESUME_LATEST | undefined; @@ -99,6 +107,51 @@ export interface CliArgs { isCommand: boolean | undefined; } +/** + * Helper to coerce comma-separated or multiple flag values into a flat array. + */ +const coerceCommaSeparated = (values: string[]): string[] => { + if (values.length === 1 && values[0] === '') { + return ['']; + } + return values.flatMap((v) => + v + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); +}; + +/** + * Pre-parses the command line arguments to find the worktree flag. + * Used for early setup before full argument parsing with settings. + */ +export function getWorktreeArg(argv: string[]): string | undefined { + const result = yargs(hideBin(argv)) + .help(false) + .version(false) + .option('worktree', { alias: 'w', type: 'string' }) + .strict(false) + .exitProcess(false) + .parseSync(); + + if (result.worktree === undefined) return undefined; + return typeof result.worktree === 'string' ? result.worktree.trim() : ''; +} + +/** + * Checks if a worktree is requested via CLI and enabled in settings. + * Returns the requested name (can be empty string for auto-generated) or undefined. + */ +export function getRequestedWorktreeName( + settings: LoadedSettings, +): string | undefined { + if (!isWorktreeEnabled(settings)) { + return undefined; + } + return getWorktreeArg(process.argv); +} + export async function parseArguments( settings: MergedSettings, ): Promise { @@ -142,6 +195,20 @@ export async function parseArguments( description: 'Execute the provided prompt and continue in interactive mode', }) + .option('worktree', { + alias: 'w', + type: 'string', + skipValidation: true, + description: + 'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.', + coerce: (value: unknown): string => { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (trimmed === '') { + return Math.random().toString(36).substring(2, 10); + } + return trimmed; + }, + }) .option('sandbox', { alias: 's', type: 'boolean', @@ -168,29 +235,31 @@ export async function parseArguments( nargs: 1, description: 'Additional policy files or directories to load (comma-separated or multiple --policy)', - coerce: (policies: string[]) => - // Handle comma-separated values - policies.flatMap((p) => - p - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - ), + coerce: coerceCommaSeparated, + }) + .option('admin-policy', { + type: 'array', + string: true, + nargs: 1, + description: + 'Additional admin policy files or directories to load (comma-separated or multiple --admin-policy)', + coerce: coerceCommaSeparated, + }) + .option('acp', { + type: 'boolean', + description: 'Starts the agent in ACP mode', }) .option('experimental-acp', { type: 'boolean', - description: 'Starts the agent in ACP mode', + description: + 'Starts the agent in ACP mode (deprecated, use --acp instead)', }) .option('allowed-mcp-server-names', { type: 'array', string: true, nargs: 1, description: 'Allowed MCP server names', - coerce: (mcpServerNames: string[]) => - // Handle comma-separated values - mcpServerNames.flatMap((mcpServerName) => - mcpServerName.split(',').map((m) => m.trim()), - ), + coerce: coerceCommaSeparated, }) .option('allowed-tools', { type: 'array', @@ -198,9 +267,7 @@ export async function parseArguments( nargs: 1, description: '[DEPRECATED: Use Policy Engine instead See https://geminicli.com/docs/core/policy-engine] Tools that are allowed to run without confirmation', - coerce: (tools: string[]) => - // Handle comma-separated values - tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + coerce: coerceCommaSeparated, }) .option('extensions', { alias: 'e', @@ -209,11 +276,7 @@ export async function parseArguments( nargs: 1, description: 'A list of extensions to use. If not provided, all extensions are used.', - coerce: (extensions: string[]) => - // Handle comma-separated values - extensions.flatMap((extension) => - extension.split(',').map((e) => e.trim()), - ), + coerce: coerceCommaSeparated, }) .option('list-extensions', { alias: 'l', @@ -233,10 +296,11 @@ export async function parseArguments( // When --resume passed without a value (`gemini --resume`): value = "" (string) // When --resume not passed at all: this `coerce` function is not called at all, and // `yargsInstance.argv.resume` is undefined. - if (value === '') { + const trimmed = value.trim(); + if (trimmed === '') { return RESUME_LATEST; } - return value; + return trimmed; }, }) .option('list-sessions', { @@ -254,9 +318,7 @@ export async function parseArguments( nargs: 1, description: 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', - coerce: (dirs: string[]) => - // Handle comma-separated values - dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), + coerce: coerceCommaSeparated, }) .option('screen-reader', { type: 'boolean', @@ -323,6 +385,9 @@ export async function parseArguments( ) { return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`; } + if (argv['worktree'] && !settings.experimental?.worktrees) { + return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.'; + } return true; }); @@ -394,36 +459,6 @@ export async function parseArguments( return result as unknown as CliArgs; } -/** - * Creates a filter function to determine if a tool should be excluded. - * - * In non-interactive mode, we want to disable tools that require user - * interaction to prevent the CLI from hanging. This function creates a predicate - * that returns `true` if a tool should be excluded. - * - * A tool is excluded if it's not in the `allowedToolsSet`. The shell tool - * has a special case: it's not excluded if any of its subcommands - * are in the `allowedTools` list. - * - * @param allowedTools A list of explicitly allowed tool names. - * @param allowedToolsSet A set of explicitly allowed tool names for quick lookups. - * @returns A function that takes a tool name and returns `true` if it should be excluded. - */ -function createToolExclusionFilter( - allowedTools: string[], - allowedToolsSet: Set, -) { - return (tool: string): boolean => { - if (tool === SHELL_TOOL_NAME) { - // If any of the allowed tools is ShellTool (even with subcommands), don't exclude it. - return !allowedTools.some((allowed) => - SHELL_TOOL_NAMES.some((shellName) => allowed.startsWith(shellName)), - ); - } - return !allowedToolsSet.has(tool); - }; -} - export function isDebugMode(argv: CliArgs): boolean { return ( argv.debug || @@ -438,6 +473,7 @@ export interface LoadCliConfigOptions { projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[]; }; + worktreeSettings?: WorktreeSettings; } export async function loadCliConfig( @@ -449,7 +485,8 @@ export async function loadCliConfig( const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); - const loadedSettings = loadSettings(cwd); + const worktreeSettings = + options.worktreeSettings ?? (await resolveWorktreeSettings(cwd)); if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; @@ -494,10 +531,32 @@ export async function loadCliConfig( ...settings.context?.fileFiltering, }; + //changes the includeDirectories to be absolute paths based on the cwd, and also include any additional directories specified via CLI args const includeDirectories = (settings.context?.includeDirectories || []) .map(resolvePath) .concat((argv.includeDirectories || []).map(resolvePath)); + // When running inside VSCode with multiple workspace folders, + // automatically add the other folders as include directories + // so Gemini has context of all open folders, not just the cwd. + const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; + if (ideWorkspacePath) { + const realCwd = resolveToRealPath(cwd); + const ideFolders = ideWorkspacePath.split(path.delimiter).filter((p) => { + const trimmedPath = p.trim(); + if (!trimmedPath) return false; + try { + return resolveToRealPath(trimmedPath) !== realCwd; + } catch (e) { + debugLogger.debug( + `[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`, + ); + return false; + } + }); + includeDirectories.push(...ideFolders); + } + const extensionManager = new ExtensionManager({ settings, requestConsent: requestConsentNonInteractive, @@ -510,7 +569,21 @@ export async function loadCliConfig( }); await extensionManager.loadExtensions(); - const experimentalJitContext = settings.experimental?.jitContext ?? false; + const extensionPlanSettings = extensionManager + .getExtensions() + .find((ext) => ext.isActive && ext.plan?.directory)?.plan; + + const experimentalJitContext = settings.experimental.jitContext; + + let extensionRegistryURI = + process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ?? + (trustedFolder ? settings.experimental?.extensionRegistryURI : undefined); + + if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) { + extensionRegistryURI = resolveToRealPath( + path.resolve(cwd, resolvePath(extensionRegistryURI)), + ); + } let memoryContent: string | HierarchicalMemory = ''; let fileCount = 0; @@ -523,7 +596,6 @@ export async function loadCliConfig( settings.context?.loadMemoryFromIncludeDirectories || false ? includeDirectories : [], - debugMode, fileService, extensionManager, trustedFolder, @@ -627,54 +699,24 @@ export async function loadCliConfig( // -i/--prompt-interactive forces interactive mode with an initial prompt const interactive = !!argv.promptInteractive || + !!argv.acp || !!argv.experimentalAcp || (!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) && !argv.isCommand); const allowedTools = argv.allowedTools || settings.tools?.allowed || []; - const allowedToolsSet = new Set(allowedTools); + + const isAcpMode = !!argv.acp || !!argv.experimentalAcp; // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; - if (!interactive) { - // ask_user requires user interaction and must be excluded in all - // non-interactive modes, regardless of the approval mode. + if (!interactive || isAcpMode) { + // The Policy Engine natively handles headless safety by translating ASK_USER + // decisions to DENY. However, we explicitly block ask_user here to guarantee + // it can never be allowed via a high-priority policy rule when no human is present. + // We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission, + // breaking conversational flows. extraExcludes.push(ASK_USER_TOOL_NAME); - - const defaultExcludes = [ - SHELL_TOOL_NAME, - EDIT_TOOL_NAME, - WRITE_FILE_TOOL_NAME, - WEB_FETCH_TOOL_NAME, - ]; - const autoEditExcludes = [SHELL_TOOL_NAME]; - - const toolExclusionFilter = createToolExclusionFilter( - allowedTools, - allowedToolsSet, - ); - - switch (approvalMode) { - case ApprovalMode.PLAN: - // In plan non-interactive mode, all tools that require approval are excluded. - // TODO(#16625): Replace this default exclusion logic with specific rules for plan mode. - extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); - break; - case ApprovalMode.DEFAULT: - // In default non-interactive mode, all tools that require approval are excluded. - extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); - break; - case ApprovalMode.AUTO_EDIT: - // In auto-edit non-interactive mode, only tools that still require a prompt are excluded. - extraExcludes.push(...autoEditExcludes.filter(toolExclusionFilter)); - break; - case ApprovalMode.YOLO: - // No extra excludes for YOLO mode. - break; - default: - // This should never happen due to validation earlier, but satisfies the linter - break; - } } const excludeTools = mergeExcludeTools(settings, extraExcludes); @@ -691,7 +733,12 @@ export async function loadCliConfig( ...settings.mcp, allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed, }, - policyPaths: argv.policy, + policyPaths: (argv.policy ?? settings.policyPaths)?.map((p) => + resolvePath(p), + ), + adminPolicyPaths: (argv.adminPolicy ?? settings.adminPolicyPaths)?.map( + (p) => resolvePath(p), + ), }; const { workspacePoliciesDir, policyUpdateConfirmationRequest } = @@ -717,6 +764,19 @@ export async function loadCliConfig( ? defaultModel : specifiedModel || defaultModel; const sandboxConfig = await loadSandboxConfig(settings, argv); + if (sandboxConfig) { + const existingPaths = sandboxConfig.allowedPaths || []; + if (settings.tools.sandboxAllowedPaths?.length) { + sandboxConfig.allowedPaths = [ + ...new Set([...existingPaths, ...settings.tools.sandboxAllowedPaths]), + ]; + } + if (settings.tools.sandboxNetworkAccess !== undefined) { + sandboxConfig.networkAccess = + sandboxConfig.networkAccess || settings.tools.sandboxNetworkAccess; + } + } + const screenReader = argv.screenReader !== undefined ? argv.screenReader @@ -752,18 +812,54 @@ export async function loadCliConfig( } } + // Apply admin-required MCP servers (injected regardless of allowlist) + if (mcpEnabled) { + const requiredMcpConfig = settings.admin?.mcp?.requiredConfig; + if (requiredMcpConfig && Object.keys(requiredMcpConfig).length > 0) { + const requiredResult = applyRequiredServers( + mcpServers ?? {}, + requiredMcpConfig, + ); + mcpServers = requiredResult.mcpServers; + + if (requiredResult.requiredServerNames.length > 0) { + coreEvents.emitConsoleLog( + 'info', + `Admin-required MCP servers injected: ${requiredResult.requiredServerNames.join(', ')}`, + ); + } + } + } + + let clientName: string | undefined = undefined; + if (isAcpMode) { + const ide = detectIdeFromEnv(); + if ( + ide && + (ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode') + ) { + clientName = `acp-${ide.name}`; + } + } + return new Config({ + acpMode: isAcpMode, + clientName, sessionId, clientVersion: await getVersion(), embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: sandboxConfig, + toolSandboxing: settings.security?.toolSandboxing ?? false, targetDir: cwd, includeDirectoryTree, includeDirectories, loadMemoryFromIncludeDirectories: settings.context?.loadMemoryFromIncludeDirectories || false, + discoveryMaxDirs: settings.context?.discoveryMaxDirs, + importFormat: settings.context?.importFormat, debugMode, question, + worktreeSettings, coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, @@ -797,6 +893,9 @@ export async function loadCliConfig( approvalMode, disableYoloMode: settings.security?.disableYoloMode || settings.admin?.secureModeEnabled, + disableAlwaysAllow: + settings.security?.disableAlwaysAllow || + settings.admin?.secureModeEnabled, showMemoryUsage: settings.ui?.showMemoryUsage || false, accessibility: { ...settings.ui?.accessibility, @@ -816,22 +915,28 @@ export async function loadCliConfig( bugCommand: settings.advanced?.bugCommand, model: resolvedModel, maxSessionTurns: settings.model?.maxSessionTurns, - experimentalZedIntegration: argv.experimentalAcp || false, + listExtensions: argv.listExtensions || false, listSessions: argv.listSessions || false, deleteSession: argv.deleteSession, enabledExtensions: argv.extensions, extensionLoader: extensionManager, + extensionRegistryURI, enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, + tracker: settings.experimental?.taskTracker, directWebFetch: settings.experimental?.directWebFetch, - planSettings: settings.general?.plan, + planSettings: settings.general?.plan?.directory + ? settings.general.plan + : (extensionPlanSettings ?? settings.general?.plan), enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, + experimentalMemoryManager: settings.experimental?.memoryManager, modelSteering: settings.experimental?.modelSteering, + topicUpdateNarration: settings.experimental?.topicUpdateNarration, toolOutputMasking: settings.experimental?.toolOutputMasking, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, @@ -842,6 +947,7 @@ export async function loadCliConfig( interactive, trustedFolder, useBackgroundColor: settings.ui?.useBackgroundColor, + useAlternateBuffer: settings.ui?.useAlternateBuffer, useRipgrep: settings.tools?.useRipgrep, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, @@ -855,14 +961,17 @@ export async function loadCliConfig( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, + gemmaModelRouter: settings.experimental?.gemmaModelRouter, fakeResponses: argv.fakeResponses, recordResponses: argv.recordResponses, retryFetchErrors: settings.general?.retryFetchErrors, + billing: settings.billing, maxAttempts: settings.general?.maxAttempts, ptyInfo: ptyInfo?.name, disableLLMCorrection: settings.tools?.disableLLMCorrection, rawOutput: argv.rawOutput, acceptRawOutputRisk: argv.acceptRawOutputRisk, + dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration, modelConfigServiceConfig: settings.modelConfigs, // TODO: loading of hooks based on workspace trust enableHooks: settings.hooksConfig.enabled, @@ -870,7 +979,7 @@ export async function loadCliConfig( hooks: settings.hooks || {}, disabledHooks: settings.hooksConfig?.disabled || [], projectHooks: projectHooks || {}, - onModelChange: (model: string) => saveModelChange(loadedSettings, model), + onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model), onReload: async () => { const refreshedSettings = loadSettings(cwd); return { @@ -892,3 +1001,48 @@ function mergeExcludeTools( ]); return Array.from(allExcludeTools); } + +async function resolveWorktreeSettings( + cwd: string, +): Promise { + let worktreePath: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], { + cwd, + }); + const toplevel = stdout.trim(); + const projectRoot = await getProjectRootForWorktree(toplevel); + + if (isGeminiWorktree(toplevel, projectRoot)) { + worktreePath = toplevel; + } + } catch (_e) { + return undefined; + } + + if (!worktreePath) { + return undefined; + } + + let worktreeBaseSha: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { + cwd: worktreePath, + }); + worktreeBaseSha = stdout.trim(); + } catch (e: unknown) { + debugLogger.debug( + `Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + + if (!worktreeBaseSha) { + return undefined; + } + + return { + name: path.basename(worktreePath), + path: worktreePath, + baseSha: worktreeBaseSha, + }; +} diff --git a/packages/cli/src/config/extension-manager-permissions.test.ts b/packages/cli/src/config/extension-manager-permissions.test.ts new file mode 100644 index 0000000000..662f30d430 --- /dev/null +++ b/packages/cli/src/config/extension-manager-permissions.test.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { copyExtension } from './extension-manager.js'; + +describe('copyExtension permissions', () => { + let tempDir: string; + let sourceDir: string; + let destDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-permission-test-')); + sourceDir = path.join(tempDir, 'source'); + destDir = path.join(tempDir, 'dest'); + fs.mkdirSync(sourceDir); + }); + + afterEach(() => { + // Ensure we can delete the temp directory by making everything writable again + const makeWritableSync = (p: string) => { + try { + const stats = fs.lstatSync(p); + fs.chmodSync(p, stats.mode | 0o700); + if (stats.isDirectory()) { + fs.readdirSync(p).forEach((child) => + makeWritableSync(path.join(p, child)), + ); + } + } catch (_e) { + // Ignore errors during cleanup + } + }; + + if (fs.existsSync(tempDir)) { + makeWritableSync(tempDir); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should make destination writable even if source is read-only', async () => { + const fileName = 'test.txt'; + const filePath = path.join(sourceDir, fileName); + fs.writeFileSync(filePath, 'hello'); + + // Make source read-only: 0o555 for directory, 0o444 for file + fs.chmodSync(filePath, 0o444); + fs.chmodSync(sourceDir, 0o555); + + // Verify source is read-only + expect(() => fs.writeFileSync(filePath, 'fail')).toThrow(); + + // Perform copy + await copyExtension(sourceDir, destDir); + + // Verify destination is writable + const destFilePath = path.join(destDir, fileName); + const destFileStats = fs.statSync(destFilePath); + const destDirStats = fs.statSync(destDir); + + // Check that owner write bits are set (0o200) + expect(destFileStats.mode & 0o200).toBe(0o200); + expect(destDirStats.mode & 0o200).toBe(0o200); + + // Verify we can actually write to the destination file + fs.writeFileSync(destFilePath, 'writable'); + expect(fs.readFileSync(destFilePath, 'utf-8')).toBe('writable'); + + // Verify we can delete the destination (which requires write bit on destDir) + fs.rmSync(destFilePath); + expect(fs.existsSync(destFilePath)).toBe(false); + }); + + it('should handle nested directories with restrictive permissions', async () => { + const subDir = path.join(sourceDir, 'subdir'); + fs.mkdirSync(subDir); + const fileName = 'nested.txt'; + const filePath = path.join(subDir, fileName); + fs.writeFileSync(filePath, 'nested content'); + + // Make nested structure read-only + fs.chmodSync(filePath, 0o444); + fs.chmodSync(subDir, 0o555); + fs.chmodSync(sourceDir, 0o555); + + // Perform copy + await copyExtension(sourceDir, destDir); + + // Verify nested destination is writable + const destSubDir = path.join(destDir, 'subdir'); + const destFilePath = path.join(destSubDir, fileName); + + expect(fs.statSync(destSubDir).mode & 0o200).toBe(0o200); + expect(fs.statSync(destFilePath).mode & 0o200).toBe(0o200); + + // Verify we can delete the whole destination tree + await fs.promises.rm(destDir, { recursive: true, force: true }); + expect(fs.existsSync(destDir)).toBe(false); + }); + + it('should not follow symlinks or modify symlink targets', async () => { + const symlinkTarget = path.join(tempDir, 'external-target'); + fs.writeFileSync(symlinkTarget, 'external content'); + // Target is read-only + fs.chmodSync(symlinkTarget, 0o444); + + const symlinkPath = path.join(sourceDir, 'symlink-file'); + fs.symlinkSync(symlinkTarget, symlinkPath); + + // Perform copy + await copyExtension(sourceDir, destDir); + + const destSymlinkPath = path.join(destDir, 'symlink-file'); + const destSymlinkStats = fs.lstatSync(destSymlinkPath); + + // Verify it is still a symlink in the destination + expect(destSymlinkStats.isSymbolicLink()).toBe(true); + + // Verify the target (external to the extension) was NOT modified + const targetStats = fs.statSync(symlinkTarget); + // Owner write bit should still NOT be set (0o200) + expect(targetStats.mode & 0o200).toBe(0o000); + + // Clean up + fs.chmodSync(symlinkTarget, 0o644); + }); +}); diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts index a76d88482d..800417de36 100644 --- a/packages/cli/src/config/extension-manager-skills.test.ts +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -15,6 +15,10 @@ import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); @@ -31,6 +35,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, homedir: mockHomedir, + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), loadAgentsFromDirectory: vi .fn() .mockImplementation(async () => ({ agents: [], errors: [] })), @@ -64,6 +71,7 @@ describe('ExtensionManager skills validation', () => { requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, + integrityManager: mockIntegrityManager, }); }); @@ -139,6 +147,7 @@ describe('ExtensionManager skills validation', () => { requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, + integrityManager: mockIntegrityManager, }); // 4. Load extensions diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index b1b21aab55..9358784a2f 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -20,7 +20,12 @@ import { import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionManager } from './extension-manager.js'; import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; -import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core'; +import { + GEMINI_DIR, + type Config, + tmpdir, + NoopSandboxManager, +} from '@google/gemini-cli-core'; import { createTestMergedSettings, SettingScope } from './settings.js'; describe('ExtensionManager theme loading', () => { @@ -117,6 +122,7 @@ describe('ExtensionManager theme loading', () => { terminalHeight: 24, showColor: false, pager: 'cat', + sandboxManager: new NoopSandboxManager(), sanitizationConfig: { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts index 4ab52e24b5..6c20737be9 100644 --- a/packages/cli/src/config/extension-manager.test.ts +++ b/packages/cli/src/config/extension-manager.test.ts @@ -9,11 +9,26 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { ExtensionManager } from './extension-manager.js'; -import { createTestMergedSettings } from './settings.js'; +import { createTestMergedSettings, type MergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; +import { themeManager } from '../ui/themes/theme-manager.js'; +import { + TrustLevel, + loadTrustedFolders, + isWorkspaceTrusted, +} from './trustedFolders.js'; +import { + getRealPath, + type CustomTheme, + IntegrityDataStatus, +} from '@google/gemini-cli-core'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); @@ -29,9 +44,32 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, homedir: mockHomedir, + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), }; }); +const testTheme: CustomTheme = { + type: 'custom', + name: 'MyTheme', + background: { + primary: '#282828', + diff: { added: '#2b3312', removed: '#341212' }, + }, + text: { + primary: '#ebdbb2', + secondary: '#a89984', + link: '#83a598', + accent: '#d3869b', + }, + status: { + success: '#b8bb26', + warning: '#fabd2f', + error: '#fb4934', + }, +}; + describe('ExtensionManager', () => { let tempHomeDir: string; let tempWorkspaceDir: string; @@ -55,10 +93,12 @@ describe('ExtensionManager', () => { workspaceDir: tempWorkspaceDir, requestConsent: vi.fn().mockResolvedValue(true), requestSetting: null, + integrityManager: mockIntegrityManager, }); }); afterEach(() => { + themeManager.clearExtensionThemes(); try { fs.rmSync(tempHomeDir, { recursive: true, force: true }); } catch (_e) { @@ -185,4 +225,416 @@ describe('ExtensionManager', () => { fs.rmSync(externalDir, { recursive: true, force: true }); }); }); + + describe('symlink handling', () => { + let extensionDir: string; + let symlinkDir: string; + + beforeEach(() => { + extensionDir = path.join(tempHomeDir, 'extension'); + symlinkDir = path.join(tempHomeDir, 'symlink-ext'); + + fs.mkdirSync(extensionDir, { recursive: true }); + + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'test-ext', version: '1.0.0' }), + ); + + fs.symlinkSync(extensionDir, symlinkDir, 'dir'); + }); + + it('preserves symlinks in installMetadata.source when linking', async () => { + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: { + security: { + folderTrust: { enabled: false }, // Disable trust for simplicity in this test + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + + // Trust the workspace to allow installation + const trustedFolders = loadTrustedFolders(); + await trustedFolders.setValue(tempWorkspaceDir, TrustLevel.TRUST_FOLDER); + + const installMetadata = { + source: symlinkDir, + type: 'link' as const, + }; + + await manager.loadExtensions(); + const extension = await manager.installOrUpdateExtension(installMetadata); + + // Desired behavior: it preserves symlinks (if they were absolute or relative as provided) + expect(extension.installMetadata?.source).toBe(symlinkDir); + }); + + it('works with the new install command logic (preserves symlink but trusts real path)', async () => { + // This simulates the logic in packages/cli/src/commands/extensions/install.ts + const absolutePath = path.resolve(symlinkDir); + const realPath = getRealPath(absolutePath); + + const settings = { + security: { + folderTrust: { enabled: true }, + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings; + + // Trust the REAL path + const trustedFolders = loadTrustedFolders(); + await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER); + + // Check trust of the symlink path + const trustResult = isWorkspaceTrusted(settings, absolutePath); + expect(trustResult.isTrusted).toBe(true); + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + + const installMetadata = { + source: absolutePath, + type: 'link' as const, + }; + + await manager.loadExtensions(); + const extension = await manager.installOrUpdateExtension(installMetadata); + + expect(extension.installMetadata?.source).toBe(absolutePath); + expect(extension.installMetadata?.source).not.toBe(realPath); + }); + + it('enforces allowedExtensions using the real path', async () => { + const absolutePath = path.resolve(symlinkDir); + const realPath = getRealPath(absolutePath); + + const settings = { + security: { + folderTrust: { enabled: false }, + // Only allow the real path, not the symlink path + allowedExtensions: [realPath.replace(/\\/g, '\\\\')], + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings; + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + + const installMetadata = { + source: absolutePath, + type: 'link' as const, + }; + + await manager.loadExtensions(); + // This should pass because realPath is allowed + const extension = await manager.installOrUpdateExtension(installMetadata); + expect(extension.name).toBe('test-ext'); + + // Now try with a settings that only allows the symlink path string + const settingsOnlySymlink = { + security: { + folderTrust: { enabled: false }, + // Only allow the symlink path string explicitly + allowedExtensions: [absolutePath.replace(/\\/g, '\\\\')], + }, + experimental: { extensionConfig: false }, + admin: { extensions: { enabled: true }, mcp: { enabled: true } }, + hooksConfig: { enabled: true }, + } as unknown as MergedSettings; + + const manager2 = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: settingsOnlySymlink, + requestConsent: () => Promise.resolve(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + + // This should FAIL because it checks the real path against the pattern + // (Unless symlinkDir === extensionDir, which shouldn't happen in this test setup) + if (absolutePath !== realPath) { + await expect( + manager2.installOrUpdateExtension(installMetadata), + ).rejects.toThrow( + /is not allowed by the "allowedExtensions" security setting/, + ); + } + }); + }); + + describe('Extension Renaming', () => { + it('should support renaming an extension during update', async () => { + // 1. Setup existing extension + const oldName = 'old-name'; + const newName = 'new-name'; + const extDir = path.join(userExtensionsDir, oldName); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: oldName, version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + await extensionManager.loadExtensions(); + + // 2. Create a temporary "new" version with a different name + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: newName, version: '1.1.0' }), + ); + fs.writeFileSync( + path.join(newSourceDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: newSourceDir }), + ); + + // 3. Update the extension + await extensionManager.installOrUpdateExtension( + { type: 'local', source: newSourceDir }, + { name: oldName, version: '1.0.0' }, + ); + + // 4. Verify old directory is gone and new one exists + expect(fs.existsSync(path.join(userExtensionsDir, oldName))).toBe(false); + expect(fs.existsSync(path.join(userExtensionsDir, newName))).toBe(true); + + // Verify the loaded state is updated + const extensions = extensionManager.getExtensions(); + expect(extensions.some((e) => e.name === newName)).toBe(true); + expect(extensions.some((e) => e.name === oldName)).toBe(false); + }); + + it('should carry over enablement status when renaming', async () => { + const oldName = 'old-name'; + const newName = 'new-name'; + const extDir = path.join(userExtensionsDir, oldName); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: oldName, version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + // Enable it + const enablementManager = extensionManager.getEnablementManager(); + enablementManager.enable(oldName, true, tempHomeDir); + + await extensionManager.loadExtensions(); + const extension = extensionManager.getExtensions()[0]; + expect(extension.isActive).toBe(true); + + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: newName, version: '1.1.0' }), + ); + fs.writeFileSync( + path.join(newSourceDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: newSourceDir }), + ); + + await extensionManager.installOrUpdateExtension( + { type: 'local', source: newSourceDir }, + { name: oldName, version: '1.0.0' }, + ); + + // Verify new name is enabled + expect(enablementManager.isEnabled(newName, tempHomeDir)).toBe(true); + // Verify old name is removed from enablement + expect(enablementManager.readConfig()[oldName]).toBeUndefined(); + }); + + it('should prevent renaming if the new name conflicts with an existing extension', async () => { + // Setup two extensions + const ext1Dir = path.join(userExtensionsDir, 'ext1'); + fs.mkdirSync(ext1Dir, { recursive: true }); + fs.writeFileSync( + path.join(ext1Dir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext1', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(ext1Dir, 'metadata.json'), + JSON.stringify({ type: 'local', source: ext1Dir }), + ); + + const ext2Dir = path.join(userExtensionsDir, 'ext2'); + fs.mkdirSync(ext2Dir, { recursive: true }); + fs.writeFileSync( + path.join(ext2Dir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext2', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(ext2Dir, 'metadata.json'), + JSON.stringify({ type: 'local', source: ext2Dir }), + ); + + await extensionManager.loadExtensions(); + + // Try to update ext1 to name 'ext2' + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: 'ext2', version: '1.1.0' }), + ); + fs.writeFileSync( + path.join(newSourceDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: newSourceDir }), + ); + + await expect( + extensionManager.installOrUpdateExtension( + { type: 'local', source: newSourceDir }, + { name: 'ext1', version: '1.0.0' }, + ), + ).rejects.toThrow(/already installed/); + }); + }); + + describe('extension integrity', () => { + it('should store integrity data during installation', async () => { + const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity'); + + const extDir = path.join(tempHomeDir, 'new-integrity-ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: 'integrity-ext', version: '1.0.0' }), + ); + + const installMetadata = { + source: extDir, + type: 'local' as const, + }; + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension(installMetadata); + + expect(storeSpy).toHaveBeenCalledWith('integrity-ext', installMetadata); + }); + + it('should store integrity data during first update', async () => { + const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity'); + const verifySpy = vi.spyOn(extensionManager, 'verifyExtensionIntegrity'); + + // Setup existing extension + const extName = 'update-integrity-ext'; + const extDir = path.join(userExtensionsDir, extName); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: extName, version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + await extensionManager.loadExtensions(); + + // Ensure no integrity data exists for this extension + verifySpy.mockResolvedValueOnce(IntegrityDataStatus.MISSING); + + const initialStatus = await extensionManager.verifyExtensionIntegrity( + extName, + { type: 'local', source: extDir }, + ); + expect(initialStatus).toBe('missing'); + + // Create new version of the extension + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: extName, version: '1.1.0' }), + ); + + const installMetadata = { + source: newSourceDir, + type: 'local' as const, + }; + + // Perform update and verify integrity was stored + await extensionManager.installOrUpdateExtension(installMetadata, { + name: extName, + version: '1.0.0', + }); + + expect(storeSpy).toHaveBeenCalledWith(extName, installMetadata); + }); + }); + + describe('early theme registration', () => { + it('should register themes with ThemeManager during loadExtensions for active extensions', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'themed-ext', + version: '1.0.0', + themes: [testTheme], + }); + + await extensionManager.loadExtensions(); + + expect(themeManager.getCustomThemeNames()).toContain( + 'MyTheme (themed-ext)', + ); + }); + + it('should not register themes for inactive extensions', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'disabled-ext', + version: '1.0.0', + themes: [testTheme], + }); + + // Disable the extension by creating an enablement override + const manager = new ExtensionManager({ + enabledExtensionOverrides: ['none'], + settings: createTestMergedSettings(), + workspaceDir: tempWorkspaceDir, + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + }); + + await manager.loadExtensions(); + + expect(themeManager.getCustomThemeNames()).not.toContain( + 'MyTheme (disabled-ext)', + ); + }); + }); }); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 93ad3f3536..04487bc5f8 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -41,6 +41,9 @@ import { loadSkillsFromDir, loadAgentsFromDirectory, homedir, + ExtensionIntegrityManager, + type IExtensionIntegrity, + type IntegrityDataStatus, type ExtensionEvents, type MCPServerConfig, type ExtensionInstallMetadata, @@ -52,6 +55,10 @@ import { applyAdminAllowlist, getAdminBlockedMcpServersMessage, CoreToolCallStatus, + loadExtensionPolicies, + isSubpath, + type PolicyRule, + type SafetyCheckerRule, HookType, } from '@google/gemini-cli-core'; import { maybeRequestConsentOrFail } from './extensions/consent.js'; @@ -85,6 +92,7 @@ interface ExtensionManagerParams { workspaceDir: string; eventEmitter?: EventEmitter; clientVersion?: string; + integrityManager?: IExtensionIntegrity; } /** @@ -94,6 +102,7 @@ interface ExtensionManagerParams { */ export class ExtensionManager extends ExtensionLoader { private extensionEnablementManager: ExtensionEnablementManager; + private integrityManager: IExtensionIntegrity; private settings: MergedSettings; private requestConsent: (consent: string) => Promise; private requestSetting: @@ -123,6 +132,26 @@ export class ExtensionManager extends ExtensionLoader { }); this.requestConsent = options.requestConsent; this.requestSetting = options.requestSetting ?? undefined; + this.integrityManager = + options.integrityManager ?? new ExtensionIntegrityManager(); + } + + getEnablementManager(): ExtensionEnablementManager { + return this.extensionEnablementManager; + } + + async verifyExtensionIntegrity( + extensionName: string, + metadata: ExtensionInstallMetadata | undefined, + ): Promise { + return this.integrityManager.verify(extensionName, metadata); + } + + async storeExtensionIntegrity( + extensionName: string, + metadata: ExtensionInstallMetadata, + ): Promise { + return this.integrityManager.store(extensionName, metadata); } setRequestConsent( @@ -149,15 +178,15 @@ export class ExtensionManager extends ExtensionLoader { async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, + requestConsentOverride?: (consent: string) => Promise, ): Promise { - if ( - this.settings.security?.allowedExtensions && - this.settings.security?.allowedExtensions.length > 0 - ) { + if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { const extensionAllowed = this.settings.security?.allowedExtensions.some( (pattern) => { try { - return new RegExp(pattern).test(installMetadata.source); + return new RegExp(pattern).test( + getRealPath(installMetadata.source), + ); } catch (e) { throw new Error( `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, @@ -206,11 +235,9 @@ export class ExtensionManager extends ExtensionLoader { await fs.promises.mkdir(extensionsDir, { recursive: true }); if (installMetadata.type === 'local' || installMetadata.type === 'link') { - installMetadata.source = getRealPath( - path.isAbsolute(installMetadata.source) - ? installMetadata.source - : path.resolve(this.workspaceDir, installMetadata.source), - ); + installMetadata.source = path.isAbsolute(installMetadata.source) + ? installMetadata.source + : path.resolve(this.workspaceDir, installMetadata.source); } let tempDir: string | undefined; @@ -239,7 +266,7 @@ export class ExtensionManager extends ExtensionLoader { (result.failureReason === 'no release data' && installMetadata.type === 'git') || // Otherwise ask the user if they would like to try a git clone. - (await this.requestConsent( + (await (requestConsentOverride ?? this.requestConsent)( `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}. Would you like to attempt to install via "git clone" instead?`, @@ -258,7 +285,7 @@ Would you like to attempt to install via "git clone" instead?`, installMetadata.type === 'local' || installMetadata.type === 'link' ) { - localSourcePath = installMetadata.source; + localSourcePath = getRealPath(installMetadata.source); } else { throw new Error(`Unsupported install type: ${installMetadata.type}`); } @@ -267,17 +294,28 @@ Would you like to attempt to install via "git clone" instead?`, newExtensionConfig = await this.loadExtensionConfig(localSourcePath); const newExtensionName = newExtensionConfig.name; + const previousName = previousExtensionConfig?.name ?? newExtensionName; const previous = this.getExtensions().find( - (installed) => installed.name === newExtensionName, + (installed) => installed.name === previousName, ); + const nameConflict = this.getExtensions().find( + (installed) => + installed.name === newExtensionName && + installed.name !== previousName, + ); + if (isUpdate && !previous) { throw new Error( - `Extension "${newExtensionName}" was not already installed, cannot update it.`, + `Extension "${previousName}" was not already installed, cannot update it.`, ); } else if (!isUpdate && previous) { throw new Error( `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, ); + } else if (isUpdate && nameConflict) { + throw new Error( + `Cannot update to "${newExtensionName}" because an extension with that name is already installed.`, + ); } const newHasHooks = fs.existsSync( @@ -294,28 +332,60 @@ Would you like to attempt to install via "git clone" instead?`, path.join(localSourcePath, 'skills'), ); const previousSkills = previous?.skills ?? []; + const isMigrating = Boolean( + previous && + previous.installMetadata && + previous.installMetadata.source !== installMetadata.source, + ); await maybeRequestConsentOrFail( newExtensionConfig, - this.requestConsent, + requestConsentOverride ?? this.requestConsent, newHasHooks, previousExtensionConfig, previousHasHooks, newSkills, previousSkills, + isMigrating, ); const extensionId = getExtensionId(newExtensionConfig, installMetadata); const destinationPath = new ExtensionStorage( newExtensionName, ).getExtensionDir(); + + if ( + (!isUpdate || newExtensionName !== previousName) && + fs.existsSync(destinationPath) + ) { + throw new Error( + `Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`, + ); + } + let previousSettings: Record | undefined; - if (isUpdate) { + let wasEnabledGlobally = false; + let wasEnabledWorkspace = false; + if (isUpdate && previousExtensionConfig) { + const previousExtensionId = previous?.installMetadata + ? getExtensionId(previousExtensionConfig, previous.installMetadata) + : extensionId; previousSettings = await getEnvContents( previousExtensionConfig, - extensionId, + previousExtensionId, this.workspaceDir, ); - await this.uninstallExtension(newExtensionName, isUpdate); + if (newExtensionName !== previousName) { + wasEnabledGlobally = this.extensionEnablementManager.isEnabled( + previousName, + homedir(), + ); + wasEnabledWorkspace = this.extensionEnablementManager.isEnabled( + previousName, + this.workspaceDir, + ); + this.extensionEnablementManager.remove(previousName); + } + await this.uninstallExtension(previousName, isUpdate); } await fs.promises.mkdir(destinationPath, { recursive: true }); @@ -369,6 +439,12 @@ Would you like to attempt to install via "git clone" instead?`, ); await fs.promises.writeFile(metadataPath, metadataString); + // Establish trust at point of installation + await this.storeExtensionIntegrity( + newExtensionConfig.name, + installMetadata, + ); + // TODO: Gracefully handle this call failing, we should back up the old // extension prior to overwriting it and then restore and restart it. extension = await this.loadExtension(destinationPath); @@ -388,6 +464,18 @@ Would you like to attempt to install via "git clone" instead?`, CoreToolCallStatus.Success, ), ); + + if (newExtensionName !== previousName) { + if (wasEnabledGlobally) { + await this.enableExtension(newExtensionName, SettingScope.User); + } + if (wasEnabledWorkspace) { + await this.enableExtension( + newExtensionName, + SettingScope.Workspace, + ); + } + } } else { await logExtensionInstallEvent( this.telemetryConfig, @@ -500,7 +588,7 @@ Would you like to attempt to install via "git clone" instead?`, protected override async startExtension(extension: GeminiCLIExtension) { await super.startExtension(extension); - if (extension.themes) { + if (extension.themes && !themeManager.hasExtensionThemes(extension.name)) { themeManager.registerExtensionThemes(extension.name, extension.themes); } } @@ -560,6 +648,13 @@ Would you like to attempt to install via "git clone" instead?`, this.loadedExtensions = builtExtensions; + // Register extension themes early so they're available at startup. + for (const ext of this.loadedExtensions) { + if (ext.isActive && ext.themes) { + themeManager.registerExtensionThemes(ext.name, ext.themes); + } + } + await Promise.all( this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)), ); @@ -622,10 +717,7 @@ Would you like to attempt to install via "git clone" instead?`, const installMetadata = loadInstallMetadata(extensionDir); let effectiveExtensionPath = extensionDir; - if ( - this.settings.security?.allowedExtensions && - this.settings.security?.allowedExtensions.length > 0 - ) { + if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { if (!installMetadata?.source) { throw new Error( `Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`, @@ -634,7 +726,9 @@ Would you like to attempt to install via "git clone" instead?`, const extensionAllowed = this.settings.security?.allowedExtensions.some( (pattern) => { try { - return new RegExp(pattern).test(installMetadata?.source); + return new RegExp(pattern).test( + getRealPath(installMetadata?.source ?? ''), + ); } catch (e) { throw new Error( `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, @@ -764,9 +858,18 @@ Would you like to attempt to install via "git clone" instead?`, } const contextFiles = getContextFileNames(config) - .map((contextFileName) => - path.join(effectiveExtensionPath, contextFileName), - ) + .map((contextFileName) => { + const contextFilePath = path.join( + effectiveExtensionPath, + contextFileName, + ); + if (!isSubpath(effectiveExtensionPath, contextFilePath)) { + throw new Error( + `Invalid context file path: "${contextFileName}". Context files must be within the extension directory.`, + ); + } + return contextFilePath; + }) .filter((contextFilePath) => fs.existsSync(contextFilePath)); const hydrationContext: VariableContext = { @@ -816,16 +919,36 @@ Would you like to attempt to install via "git clone" instead?`, let skills = await loadSkillsFromDir( path.join(effectiveExtensionPath, 'skills'), ); - skills = skills.map((skill) => - recursivelyHydrateStrings(skill, hydrationContext), - ); + skills = skills.map((skill) => ({ + ...recursivelyHydrateStrings(skill, hydrationContext), + extensionName: config.name, + })); + + let rules: PolicyRule[] | undefined; + let checkers: SafetyCheckerRule[] | undefined; + + const policyDir = path.join(effectiveExtensionPath, 'policies'); + if (fs.existsSync(policyDir)) { + const result = await loadExtensionPolicies(config.name, policyDir); + rules = result.rules; + checkers = result.checkers; + + if (result.errors.length > 0) { + for (const error of result.errors) { + debugLogger.warn( + `[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`, + ); + } + } + } const agentLoadResult = await loadAgentsFromDirectory( path.join(effectiveExtensionPath, 'agents'), ); - agentLoadResult.agents = agentLoadResult.agents.map((agent) => - recursivelyHydrateStrings(agent, hydrationContext), - ); + agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({ + ...recursivelyHydrateStrings(agent, hydrationContext), + extensionName: config.name, + })); // Log errors but don't fail the entire extension load for (const error of agentLoadResult.errors) { @@ -840,6 +963,7 @@ Would you like to attempt to install via "git clone" instead?`, path: effectiveExtensionPath, contextFiles, installMetadata, + migratedTo: config.migratedTo, mcpServers: config.mcpServers, excludeTools: config.excludeTools, hooks, @@ -853,6 +977,9 @@ Would you like to attempt to install via "git clone" instead?`, skills, agents: agentLoadResult.agents, themes: config.themes, + rules, + checkers, + plan: config.plan, }; } catch (e) { debugLogger.error( @@ -1114,11 +1241,32 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { return Object.freeze(rest); } +/** + * Recursively ensures that the owner has write permissions for all files + * and directories within the target path. + */ +async function makeWritableRecursive(targetPath: string): Promise { + const stats = await fs.promises.lstat(targetPath); + + if (stats.isDirectory()) { + // Ensure directory is rwx for the owner (0o700) + await fs.promises.chmod(targetPath, stats.mode | 0o700); + const children = await fs.promises.readdir(targetPath); + for (const child of children) { + await makeWritableRecursive(path.join(targetPath, child)); + } + } else if (stats.isFile()) { + // Ensure file is rw for the owner (0o600) + await fs.promises.chmod(targetPath, stats.mode | 0o600); + } +} + export async function copyExtension( source: string, destination: string, ): Promise { await fs.promises.cp(source, destination, { recursive: true }); + await makeWritableRecursive(destination); } function getContextFileNames(config: ExtensionConfig): string[] { diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index affcd0cef0..ef7e61cf25 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -31,6 +31,7 @@ import { loadSettings, createTestMergedSettings, SettingScope, + resetSettingsCacheForTesting, } from './settings.js'; import { isWorkspaceTrusted, @@ -102,6 +103,10 @@ const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); const mockLogExtensionUpdateEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionDisable = vi.hoisted(() => vi.fn()); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -117,6 +122,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ExtensionInstallEvent: vi.fn(), ExtensionUninstallEvent: vi.fn(), ExtensionDisableEvent: vi.fn(), + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), KeychainTokenStorage: vi.fn().mockImplementation(() => ({ getSecret: vi.fn(), setSecret: vi.fn(), @@ -161,6 +169,7 @@ describe('extension tests', () => { beforeEach(() => { vi.clearAllMocks(); + resetSettingsCacheForTesting(); keychainData = {}; mockKeychainStorage = { getSecret: vi @@ -212,6 +221,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings, + integrityManager: mockIntegrityManager, }); resetTrustedFoldersForTesting(); }); @@ -239,6 +249,27 @@ describe('extension tests', () => { expect(extensions[0].name).toBe('test-extension'); }); + it('should skip the extension if a context file path is outside the extension directory and log an error', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'traversal-extension', + version: '1.0.0', + contextFileName: '../secret.txt', + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(0); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'traversal-extension: Invalid context file path: "../secret.txt"', + ), + ); + consoleSpy.mockRestore(); + }); + it('should load context file path when GEMINI.md is present', async () => { createExtension({ extensionsDir: userExtensionsDir, @@ -363,6 +394,111 @@ describe('extension tests', () => { ]); }); + it('should load extension policies from the policies directory', async () => { + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'policy-extension', + version: '1.0.0', + }); + + const policiesDir = path.join(extDir, 'policies'); + fs.mkdirSync(policiesDir); + + const policiesContent = ` +[[rule]] +toolName = "deny_tool" +decision = "deny" +priority = 500 + +[[rule]] +toolName = "ask_tool" +decision = "ask_user" +priority = 100 +`; + fs.writeFileSync( + path.join(policiesDir, 'policies.toml'), + policiesContent, + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + + expect(extension.rules).toBeDefined(); + expect(extension.rules).toHaveLength(2); + expect( + extension.rules!.find((r) => r.toolName === 'deny_tool')?.decision, + ).toBe('deny'); + expect( + extension.rules!.find((r) => r.toolName === 'ask_tool')?.decision, + ).toBe('ask_user'); + // Verify source is prefixed + expect(extension.rules![0].source).toContain( + 'Extension (policy-extension):', + ); + }); + + it('should ignore ALLOW rules and YOLO mode from extension policies for security', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'security-test-extension', + version: '1.0.0', + }); + + const policiesDir = path.join(extDir, 'policies'); + fs.mkdirSync(policiesDir); + + const policiesContent = ` +[[rule]] +toolName = "allow_tool" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "yolo_tool" +decision = "ask_user" +priority = 100 +modes = ["yolo"] + +[[safety_checker]] +toolName = "yolo_check" +priority = 100 +modes = ["yolo"] +[safety_checker.checker] +type = "external" +name = "yolo-checker" +`; + fs.writeFileSync( + path.join(policiesDir, 'policies.toml'), + policiesContent, + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + + // ALLOW rules and YOLO rules/checkers should be filtered out + expect(extension.rules).toBeDefined(); + expect(extension.rules).toHaveLength(0); + expect(extension.checkers).toBeDefined(); + expect(extension.checkers).toHaveLength(0); + + // Should have logged warnings + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('attempted to contribute an ALLOW rule'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('attempted to contribute a rule for YOLO mode'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'attempted to contribute a safety checker for YOLO mode', + ), + ); + consoleSpy.mockRestore(); + }); + it('should hydrate ${extensionPath} correctly for linked extensions', async () => { const sourceExtDir = getRealPath( createExtension({ @@ -526,7 +662,7 @@ describe('extension tests', () => { expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}'); }); - it('should skip extensions with invalid JSON and log a warning', async () => { + it('should skip an extension with invalid JSON config and log an error', async () => { const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); @@ -540,7 +676,7 @@ describe('extension tests', () => { // Bad extension const badExtDir = path.join(userExtensionsDir, 'bad-ext'); - fs.mkdirSync(badExtDir); + fs.mkdirSync(badExtDir, { recursive: true }); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed @@ -548,7 +684,7 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( + expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, ), @@ -557,7 +693,7 @@ describe('extension tests', () => { consoleSpy.mockRestore(); }); - it('should skip extensions with missing name and log a warning', async () => { + it('should skip an extension with missing "name" in config and log an error', async () => { const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); @@ -571,7 +707,7 @@ describe('extension tests', () => { // Bad extension const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name'); - fs.mkdirSync(badExtDir); + fs.mkdirSync(badExtDir, { recursive: true }); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); @@ -579,7 +715,7 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( + expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, ), @@ -607,7 +743,7 @@ describe('extension tests', () => { expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined(); }); - it('should throw an error for invalid extension names', async () => { + it('should log an error for invalid extension names during loading', async () => { const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); @@ -626,7 +762,7 @@ describe('extension tests', () => { consoleSpy.mockRestore(); }); - it('should not load github extensions if blockGitExtensions is set', async () => { + it('should not load github extensions and log a warning if blockGitExtensions is set', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, @@ -646,6 +782,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: blockGitExtensionsSetting, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === 'my-ext'); @@ -679,6 +816,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: extensionAllowlistSetting, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -686,7 +824,7 @@ describe('extension tests', () => { expect(extensions[0].name).toBe('my-ext'); }); - it('should not load disallowed extensions if the allowlist is set.', async () => { + it('should not load disallowed extensions and log a warning if the allowlist is set.', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, @@ -707,6 +845,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: extensionAllowlistSetting, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === 'my-ext'); @@ -734,6 +873,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: loadedSettings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -757,6 +897,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: loadedSettings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -781,6 +922,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: loadedSettings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -919,6 +1061,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -954,6 +1097,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -1178,6 +1322,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: blockGitExtensionsSetting, + integrityManager: mockIntegrityManager, }); await extensionManager.loadExtensions(); await expect( @@ -1202,6 +1347,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: allowedExtensionsSetting, + integrityManager: mockIntegrityManager, }); await extensionManager.loadExtensions(); await expect( @@ -1549,6 +1695,7 @@ ${INSTALL_WARNING_MESSAGE}`, requestConsent: mockRequestConsent, requestSetting: null, settings: loadSettings(tempWorkspaceDir).merged, + integrityManager: mockIntegrityManager, }); await extensionManager.loadExtensions(); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 815cf23ece..564c4fbb6f 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -33,6 +33,19 @@ export interface ExtensionConfig { * These themes will be registered when the extension is activated. */ themes?: CustomTheme[]; + /** + * Planning features configuration contributed by this extension. + */ + plan?: { + /** + * The directory where planning artifacts are stored. + */ + directory?: string; + }; + /** + * Used to migrate an extension to a new repository source. + */ + migratedTo?: string; } export interface ExtensionUpdateInfo { diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts index 4b9699d5e3..66eaab914b 100644 --- a/packages/cli/src/config/extensionRegistryClient.test.ts +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -13,14 +13,24 @@ import { afterEach, type Mock, } from 'vitest'; +import * as fs from 'node:fs/promises'; import { ExtensionRegistryClient, type RegistryExtension, } from './extensionRegistryClient.js'; -import { fetchWithTimeout } from '@google/gemini-cli-core'; +import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core'; -vi.mock('@google/gemini-cli-core', () => ({ - fetchWithTimeout: vi.fn(), +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + fetchWithTimeout: vi.fn(), + }; +}); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), })); const mockExtensions: RegistryExtension[] = [ @@ -279,4 +289,32 @@ describe('ExtensionRegistryClient', () => { expect(ids).not.toContain('dataplex'); expect(ids).toContain('conductor'); }); + + it('should fetch extensions from a local file path', async () => { + const filePath = '/path/to/extensions.json'; + const clientWithFile = new ExtensionRegistryClient(filePath); + const mockReadFile = vi.mocked(fs.readFile); + mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions)); + + const result = await clientWithFile.getExtensions(); + expect(result.extensions).toHaveLength(3); + expect(mockReadFile).toHaveBeenCalledWith( + resolveToRealPath(filePath), + 'utf-8', + ); + }); + + it('should fetch extensions from a file:// URL', async () => { + const fileUrl = 'file:///path/to/extensions.json'; + const clientWithFileUrl = new ExtensionRegistryClient(fileUrl); + const mockReadFile = vi.mocked(fs.readFile); + mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions)); + + const result = await clientWithFileUrl.getExtensions(); + expect(result.extensions).toHaveLength(3); + expect(mockReadFile).toHaveBeenCalledWith( + resolveToRealPath(fileUrl), + 'utf-8', + ); + }); }); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts index bf09aabe77..4b47c215ec 100644 --- a/packages/cli/src/config/extensionRegistryClient.ts +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fetchWithTimeout } from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import { + fetchWithTimeout, + resolveToRealPath, + isPrivateIp, +} from '@google/gemini-cli-core'; import { AsyncFzf } from 'fzf'; export interface RegistryExtension { @@ -29,12 +34,19 @@ export interface RegistryExtension { } export class ExtensionRegistryClient { - private static readonly REGISTRY_URL = + static readonly DEFAULT_REGISTRY_URL = 'https://geminicli.com/extensions.json'; private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds private static fetchPromise: Promise | null = null; + private readonly registryURI: string; + + constructor(registryURI?: string) { + this.registryURI = + registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL; + } + /** @internal */ static resetCache() { ExtensionRegistryClient.fetchPromise = null; @@ -97,18 +109,34 @@ export class ExtensionRegistryClient { return ExtensionRegistryClient.fetchPromise; } + const uri = this.registryURI; ExtensionRegistryClient.fetchPromise = (async () => { try { - const response = await fetchWithTimeout( - ExtensionRegistryClient.REGISTRY_URL, - ExtensionRegistryClient.FETCH_TIMEOUT_MS, - ); - if (!response.ok) { - throw new Error(`Failed to fetch extensions: ${response.statusText}`); - } + if (uri.startsWith('http')) { + if (isPrivateIp(uri)) { + throw new Error( + 'Private IP addresses are not allowed for the extension registry.', + ); + } + const response = await fetchWithTimeout( + uri, + ExtensionRegistryClient.FETCH_TIMEOUT_MS, + ); + if (!response.ok) { + throw new Error( + `Failed to fetch extensions: ${response.statusText}`, + ); + } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return (await response.json()) as RegistryExtension[]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (await response.json()) as RegistryExtension[]; + } else { + // Handle local file path + const filePath = resolveToRealPath(uri); + const content = await fs.readFile(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as RegistryExtension[]; + } } catch (error) { ExtensionRegistryClient.fetchPromise = null; throw error; diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg new file mode 100644 index 0000000000..34161f8eb0 --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg @@ -0,0 +1,13 @@ + + + + + Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates. + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg index 6f5879df4c..fbaaa599d4 100644 --- a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg @@ -10,11 +10,15 @@ * server2 (remote): https://remote.com This extension will append info to your gemini.md context using my-context.md This extension will exclude the following core tools: tool1,tool2 - Agent Skills: + Agent Skills: This extension will install the following agent skills: - * skill1: desc1 + * + skill1 + : desc1 (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory) - * skill2: desc2 + * + skill2 + : desc2 (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory) The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg index 3fff32664a..b57af41589 100644 --- a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg @@ -5,9 +5,11 @@ Installing extension "test-ext". - Agent Skills: + Agent Skills: This extension will install the following agent skills: - * locked-skill: A skill in a locked dir + * + locked-skill + : A skill in a locked dir (Source: /mock/temp/dir/locked/SKILL.md) ⚠️ (Could not count items in directory) The extension you are about to install may have been created by a third-party developer and sourced diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg index c52724836e..32b9d8e0a3 100644 --- a/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg @@ -6,7 +6,9 @@ Installing agent skill(s) from "https://example.com/repo.git". The following agent skill(s) will be installing: - * skill1: desc1 + * + skill1 + : desc1 (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory) Install Destination: /mock/target/dir Agent skills inject specialized instructions and domain-specific knowledge into the agent's system diff --git a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap index d8fe99d004..59b00995eb 100644 --- a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap +++ b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap @@ -24,6 +24,15 @@ of extensions. Please carefully inspect any extension and its source code before understand the permissions it requires and the actions it may perform." `; +exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = ` +"Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates. + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform." +`; + exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = ` "Installing extension "test-ext". This extension will run the following MCP servers: diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts index 04e6cae69f..8de884cdd5 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -59,8 +59,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); async function expectConsentSnapshot(consentString: string) { - const renderResult = render(React.createElement(Text, null, consentString)); - await renderResult.waitUntilReady(); + const renderResult = await render( + React.createElement(Text, null, consentString), + ); await expect(renderResult).toMatchSvgSnapshot(); } @@ -287,6 +288,25 @@ describe('consent', () => { expect(requestConsent).toHaveBeenCalledTimes(1); }); + it('should request consent if extension is migrated', async () => { + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + false, + { ...baseConfig, name: 'old-ext' }, + false, + [], + [], + true, + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + let consentString = requestConsent.mock.calls[0][0] as string; + consentString = normalizePathsForSnapshot(consentString, tempDir); + await expectConsentSnapshot(consentString); + }); + it('should request consent if skills change', async () => { const skill1Dir = path.join(tempDir, 'skill1'); const skill2Dir = path.join(tempDir, 'skill2'); diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts index 9a63054d12..5c35c0d899 100644 --- a/packages/cli/src/config/extensions/consent.ts +++ b/packages/cli/src/config/extensions/consent.ts @@ -148,11 +148,30 @@ async function extensionConsentString( extensionConfig: ExtensionConfig, hasHooks: boolean, skills: SkillDefinition[] = [], + previousName?: string, + wasMigrated?: boolean, ): Promise { const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig); const output: string[] = []; const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {}); - output.push(`Installing extension "${sanitizedConfig.name}".`); + + if (wasMigrated) { + if (previousName && previousName !== sanitizedConfig.name) { + output.push( + `Migrating extension "${previousName}" to a new repository, renaming to "${sanitizedConfig.name}", and installing updates.`, + ); + } else { + output.push( + `Migrating extension "${sanitizedConfig.name}" to a new repository and installing updates.`, + ); + } + } else if (previousName && previousName !== sanitizedConfig.name) { + output.push( + `Renaming extension "${previousName}" to "${sanitizedConfig.name}" and installing updates.`, + ); + } else { + output.push(`Installing extension "${sanitizedConfig.name}".`); + } if (mcpServerEntries.length) { output.push('This extension will run the following MCP servers:'); @@ -231,11 +250,14 @@ export async function maybeRequestConsentOrFail( previousHasHooks?: boolean, skills: SkillDefinition[] = [], previousSkills: SkillDefinition[] = [], + isMigrating: boolean = false, ) { const extensionConsent = await extensionConsentString( extensionConfig, hasHooks, skills, + previousExtensionConfig?.name, + isMigrating, ); if (previousExtensionConfig) { const previousExtensionConsent = await extensionConsentString( diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index 7139c5d2c2..89282fcd8a 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -16,21 +16,14 @@ import { } from '@google/gemini-cli-core'; import { ExtensionManager } from '../extension-manager.js'; import { createTestMergedSettings } from '../settings.js'; +import { isWorkspaceTrusted } from '../trustedFolders.js'; // --- Mocks --- vi.mock('node:fs', async (importOriginal) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, - default: { - ...actual.default, - existsSync: vi.fn(), - statSync: vi.fn(), - lstatSync: vi.fn(), - realpathSync: vi.fn((p) => p), - }, existsSync: vi.fn(), statSync: vi.fn(), lstatSync: vi.fn(), @@ -38,10 +31,13 @@ vi.mock('node:fs', async (importOriginal) => { promises: { ...actual.promises, mkdir: vi.fn(), + readdir: vi.fn(), writeFile: vi.fn(), rm: vi.fn(), cp: vi.fn(), readFile: vi.fn(), + lstat: vi.fn(), + chmod: vi.fn(), }, }; }); @@ -75,6 +71,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { Config: vi.fn().mockImplementation(() => ({ getEnableExtensionReloading: vi.fn().mockReturnValue(true), })), + KeychainService: class { + isAvailable = vi.fn().mockResolvedValue(true); + getPassword = vi.fn().mockResolvedValue('test-key'); + setPassword = vi.fn().mockResolvedValue(undefined); + }, + ExtensionIntegrityManager: class { + verify = vi.fn().mockResolvedValue('verified'); + store = vi.fn().mockResolvedValue(undefined); + }, + IntegrityDataStatus: { + VERIFIED: 'verified', + MISSING: 'missing', + INVALID: 'invalid', + }, }; }); @@ -134,13 +144,26 @@ describe('extensionUpdates', () => { vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined); vi.mocked(fs.promises.rm).mockResolvedValue(undefined); vi.mocked(fs.promises.cp).mockResolvedValue(undefined); + vi.mocked(fs.promises.readdir).mockResolvedValue([]); + vi.mocked(fs.promises.lstat).mockResolvedValue({ + isDirectory: () => true, + mode: 0o755, + } as unknown as fs.Stats); + vi.mocked(fs.promises.chmod).mockResolvedValue(undefined); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + vi.mocked(getMissingSettings).mockResolvedValue([]); // Allow directories to exist by default to satisfy Config/WorkspaceContext checks vi.mocked(fs.existsSync).mockReturnValue(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as fs.Stats); + vi.mocked(fs.lstatSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as fs.Stats); vi.mocked(fs.realpathSync).mockImplementation((p) => p as string); tempWorkspaceDir = '/mock/workspace'; @@ -202,11 +225,10 @@ describe('extensionUpdates', () => { ]); vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); // Mock loadExtension to return something so the method doesn't crash at the end - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({ + vi.spyOn(manager, 'loadExtension').mockResolvedValue({ name: 'test-ext', version: '1.1.0', - } as GeminiCLIExtension); + } as unknown as GeminiCLIExtension); // 4. Mock External Helpers // This is the key fix: we explicitly mock `getMissingSettings` to return @@ -235,5 +257,52 @@ describe('extensionUpdates', () => { ), ); }); + + it('should store integrity data after update', async () => { + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.1.0', + }; + + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + }; + + const installMetadata: ExtensionInstallMetadata = { + source: '/mock/source', + type: 'local', + }; + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: createTestMergedSettings(), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + }); + + await manager.loadExtensions(); + vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig); + vi.spyOn(manager, 'getExtensions').mockReturnValue([ + { + name: 'test-ext', + version: '1.0.0', + installMetadata, + path: '/mock/extensions/test-ext', + isActive: true, + } as unknown as GeminiCLIExtension, + ]); + vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); + vi.spyOn(manager, 'loadExtension').mockResolvedValue({ + name: 'test-ext', + version: '1.1.0', + } as unknown as GeminiCLIExtension); + + const storeSpy = vi.spyOn(manager, 'storeExtensionIntegrity'); + + await manager.installOrUpdateExtension(installMetadata, previousConfig); + + expect(storeSpy).toHaveBeenCalledWith('test-ext', installMetadata); + }); }); }); diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index c3ff5905b5..830506c002 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -285,6 +285,23 @@ describe('github.ts', () => { ExtensionUpdateState.NOT_UPDATABLE, ); }); + + it('should check migratedTo source if present and return UPDATE_AVAILABLE', async () => { + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'new-url' } }, + ]); + mockGit.listRemote.mockResolvedValue('hash\tHEAD'); + mockGit.revparse.mockResolvedValue('hash'); + + const ext = { + path: '/path', + migratedTo: 'new-url', + installMetadata: { type: 'git', source: 'old-url' }, + } as unknown as GeminiCLIExtension; + expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + }); }); describe('downloadFromGitHubRelease', () => { diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index e8b35a6184..156fe78309 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -5,9 +5,9 @@ */ import { simpleGit } from 'simple-git'; -import { getErrorMessage } from '../../utils/errors.js'; import { debugLogger, + getErrorMessage, type ExtensionInstallMetadata, type GeminiCLIExtension, } from '@google/gemini-cli-core'; @@ -203,6 +203,24 @@ export async function checkForExtensionUpdate( ) { return ExtensionUpdateState.NOT_UPDATABLE; } + + if (extension.migratedTo) { + const migratedState = await checkForExtensionUpdate( + { + ...extension, + installMetadata: { ...installMetadata, source: extension.migratedTo }, + migratedTo: undefined, + }, + extensionManager, + ); + if ( + migratedState === ExtensionUpdateState.UPDATE_AVAILABLE || + migratedState === ExtensionUpdateState.UP_TO_DATE + ) { + return ExtensionUpdateState.UPDATE_AVAILABLE; + } + } + try { if (installMetadata.type === 'git') { const git = simpleGit(extension.path); diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index cb5bba2a11..a0a959bebd 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -15,14 +15,16 @@ import { type ExtensionUpdateStatus, } from '../../ui/state/extensions.js'; import { ExtensionStorage } from './storage.js'; -import { copyExtension } from '../extension-manager.js'; +import { type ExtensionManager, copyExtension } from '../extension-manager.js'; import { checkForExtensionUpdate } from './github.js'; import { loadInstallMetadata } from '../extension.js'; import * as fs from 'node:fs'; -import type { ExtensionManager } from '../extension-manager.js'; -import type { GeminiCLIExtension } from '@google/gemini-cli-core'; +import { + type GeminiCLIExtension, + type ExtensionInstallMetadata, + IntegrityDataStatus, +} from '@google/gemini-cli-core'; -// Mock dependencies vi.mock('./storage.js', () => ({ ExtensionStorage: { createTmpDir: vi.fn(), @@ -65,8 +67,18 @@ describe('Extension Update Logic', () => { beforeEach(() => { vi.clearAllMocks(); mockExtensionManager = { - loadExtensionConfig: vi.fn(), - installOrUpdateExtension: vi.fn(), + loadExtensionConfig: vi.fn().mockResolvedValue({ + name: 'test-extension', + version: '1.0.0', + }), + installOrUpdateExtension: vi.fn().mockResolvedValue({ + ...mockExtension, + version: '1.1.0', + }), + verifyExtensionIntegrity: vi + .fn() + .mockResolvedValue(IntegrityDataStatus.VERIFIED), + storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined), } as unknown as ExtensionManager; mockDispatch = vi.fn(); @@ -93,7 +105,7 @@ describe('Extension Update Logic', () => { it('should throw error and set state to ERROR if install metadata type is unknown', async () => { vi.mocked(loadInstallMetadata).mockReturnValue({ type: undefined, - } as unknown as import('@google/gemini-cli-core').ExtensionInstallMetadata); + } as unknown as ExtensionInstallMetadata); await expect( updateExtension( @@ -184,6 +196,54 @@ describe('Extension Update Logic', () => { }); }); + it('should migrate source if migratedTo is set and an update is available', async () => { + vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( + Promise.resolve({ + name: 'test-extension', + version: '1.0.0', + }), + ); + vi.mocked( + mockExtensionManager.installOrUpdateExtension, + ).mockResolvedValue({ + ...mockExtension, + version: '1.1.0', + }); + vi.mocked(checkForExtensionUpdate).mockResolvedValue( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + + const extensionWithMigratedTo = { + ...mockExtension, + migratedTo: 'https://new-source.com/repo.git', + }; + + await updateExtension( + extensionWithMigratedTo, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ); + + expect(checkForExtensionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + installMetadata: expect.objectContaining({ + source: 'https://new-source.com/repo.git', + }), + }), + mockExtensionManager, + ); + + expect( + mockExtensionManager.installOrUpdateExtension, + ).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'https://new-source.com/repo.git', + }), + expect.anything(), + ); + }); + it('should set state to UPDATED if enableExtensionReloading is true', async () => { vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( Promise.resolve({ @@ -248,6 +308,77 @@ describe('Extension Update Logic', () => { }); expect(fs.promises.rm).toHaveBeenCalled(); }); + + describe('Integrity Verification', () => { + it('should fail update with security alert if integrity is invalid', async () => { + vi.mocked( + mockExtensionManager.verifyExtensionIntegrity, + ).mockResolvedValue(IntegrityDataStatus.INVALID); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow( + 'Extension test-extension cannot be updated. Extension integrity cannot be verified.', + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.ERROR, + }, + }); + }); + + it('should establish trust on first update if integrity data is missing', async () => { + vi.mocked( + mockExtensionManager.verifyExtensionIntegrity, + ).mockResolvedValue(IntegrityDataStatus.MISSING); + + await updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ); + + // Verify updateExtension delegates to installOrUpdateExtension, + // which is responsible for establishing trust internally. + expect( + mockExtensionManager.installOrUpdateExtension, + ).toHaveBeenCalled(); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.UPDATED_NEEDS_RESTART, + }, + }); + }); + + it('should throw if integrity manager throws', async () => { + vi.mocked( + mockExtensionManager.verifyExtensionIntegrity, + ).mockRejectedValue(new Error('Verification failed')); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow( + 'Extension test-extension cannot be updated. Verification failed', + ); + }); + }); }); describe('updateAllUpdatableExtensions', () => { diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index bdb43e0975..c4b7113530 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -11,9 +11,13 @@ import { } from '../../ui/state/extensions.js'; import { loadInstallMetadata } from '../extension.js'; import { checkForExtensionUpdate } from './github.js'; -import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core'; +import { + debugLogger, + getErrorMessage, + type GeminiCLIExtension, + IntegrityDataStatus, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs'; -import { getErrorMessage } from '../../utils/errors.js'; import { copyExtension, type ExtensionManager } from '../extension-manager.js'; import { ExtensionStorage } from './storage.js'; @@ -48,6 +52,26 @@ export async function updateExtension( `Extension ${extension.name} cannot be updated, type is unknown.`, ); } + + try { + const status = await extensionManager.verifyExtensionIntegrity( + extension.name, + installMetadata, + ); + + if (status === IntegrityDataStatus.INVALID) { + throw new Error('Extension integrity cannot be verified'); + } + } catch (e) { + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, + }); + throw new Error( + `Extension ${extension.name} cannot be updated. ${getErrorMessage(e)}. To fix this, reinstall the extension.`, + ); + } + if (installMetadata?.type === 'link') { dispatchExtensionStateUpdate({ type: 'SET_STATE', @@ -55,6 +79,24 @@ export async function updateExtension( }); throw new Error(`Extension is linked so does not need to be updated`); } + + if (extension.migratedTo) { + const migratedState = await checkForExtensionUpdate( + { + ...extension, + installMetadata: { ...installMetadata, source: extension.migratedTo }, + migratedTo: undefined, + }, + extensionManager, + ); + if ( + migratedState === ExtensionUpdateState.UPDATE_AVAILABLE || + migratedState === ExtensionUpdateState.UP_TO_DATE + ) { + installMetadata.source = extension.migratedTo; + } + } + const originalVersion = extension.version; const tempDir = await ExtensionStorage.createTmpDir(); diff --git a/packages/cli/src/config/extensions/variables.test.ts b/packages/cli/src/config/extensions/variables.test.ts index 576546ef04..5f57fe19fe 100644 --- a/packages/cli/src/config/extensions/variables.test.ts +++ b/packages/cli/src/config/extensions/variables.test.ts @@ -124,4 +124,30 @@ describe('recursivelyHydrateStrings', () => { const result = recursivelyHydrateStrings(obj, context); expect(result).toEqual(obj); }); + + it('should not allow prototype pollution via __proto__', () => { + const payload = JSON.parse('{"__proto__": {"polluted": "yes"}}'); + const result = recursivelyHydrateStrings(payload, context); + + expect(result.polluted).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(result, 'polluted')).toBe( + false, + ); + }); + + it('should not allow prototype pollution via constructor', () => { + const payload = JSON.parse( + '{"constructor": {"prototype": {"polluted": "yes"}}}', + ); + const result = recursivelyHydrateStrings(payload, context); + + expect(result.polluted).toBeUndefined(); + }); + + it('should not allow prototype pollution via prototype', () => { + const payload = JSON.parse('{"prototype": {"polluted": "yes"}}'); + const result = recursivelyHydrateStrings(payload, context); + + expect(result.polluted).toBeUndefined(); + }); }); diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 3a79fc705f..b5b14c9643 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -8,6 +8,16 @@ import * as path from 'node:path'; import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js'; import { GEMINI_DIR } from '@google/gemini-cli-core'; +/** + * Represents a set of keys that will be considered invalid while unmarshalling + * JSON in recursivelyHydrateStrings. + */ +const UNMARSHALL_KEY_IGNORE_LIST: Set = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); + export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; @@ -65,7 +75,10 @@ export function recursivelyHydrateStrings( if (typeof obj === 'object' && obj !== null) { const newObj: Record = {}; for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { + if ( + !UNMARSHALL_KEY_IGNORE_LIST.has(key) && + Object.prototype.hasOwnProperty.call(obj, key) + ) { newObj[key] = recursivelyHydrateStrings( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (obj as Record)[key], diff --git a/packages/cli/src/config/footerItems.test.ts b/packages/cli/src/config/footerItems.test.ts new file mode 100644 index 0000000000..420246811b --- /dev/null +++ b/packages/cli/src/config/footerItems.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { deriveItemsFromLegacySettings } from './footerItems.js'; +import { createMockSettings } from '../test-utils/settings.js'; + +describe('deriveItemsFromLegacySettings', () => { + it('returns defaults when no legacy settings are customized', () => { + const settings = createMockSettings({ + ui: { footer: { hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toEqual([ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'quota', + ]); + }); + + it('removes workspace when hideCWD is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideCWD: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('workspace'); + }); + + it('removes sandbox when hideSandboxStatus is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('sandbox'); + }); + + it('removes model-name, context-used, and quota when hideModelInfo is true', () => { + const settings = createMockSettings({ + ui: { footer: { hideModelInfo: true, hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).not.toContain('model-name'); + expect(items).not.toContain('context-used'); + expect(items).not.toContain('quota'); + }); + + it('includes context-used when hideContextPercentage is false', () => { + const settings = createMockSettings({ + ui: { footer: { hideContextPercentage: false } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toContain('context-used'); + // Should be after model-name + const modelIdx = items.indexOf('model-name'); + const contextIdx = items.indexOf('context-used'); + expect(contextIdx).toBe(modelIdx + 1); + }); + + it('includes memory-usage when showMemoryUsage is true', () => { + const settings = createMockSettings({ + ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toContain('memory-usage'); + }); + + it('handles combination of settings', () => { + const settings = createMockSettings({ + ui: { + showMemoryUsage: true, + footer: { + hideCWD: true, + hideModelInfo: true, + hideContextPercentage: false, + }, + }, + }).merged; + const items = deriveItemsFromLegacySettings(settings); + expect(items).toEqual([ + 'git-branch', + 'sandbox', + 'context-used', + 'memory-usage', + ]); + }); +}); diff --git a/packages/cli/src/config/footerItems.ts b/packages/cli/src/config/footerItems.ts new file mode 100644 index 0000000000..8410d0b5ec --- /dev/null +++ b/packages/cli/src/config/footerItems.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { MergedSettings } from './settings.js'; + +export const ALL_ITEMS = [ + { + id: 'workspace', + header: 'workspace (/directory)', + description: 'Current working directory', + }, + { + id: 'git-branch', + header: 'branch', + description: 'Current git branch name (not shown when unavailable)', + }, + { + id: 'sandbox', + header: 'sandbox', + description: 'Sandbox type and trust indicator', + }, + { + id: 'model-name', + header: '/model', + description: 'Current model identifier', + }, + { + id: 'context-used', + header: 'context', + description: 'Percentage of context window used', + }, + { + id: 'quota', + header: '/stats', + description: 'Remaining usage on daily limit (not shown when unavailable)', + }, + { + id: 'memory-usage', + header: 'memory', + description: 'Memory used by the application', + }, + { + id: 'session-id', + header: 'session', + description: 'Unique identifier for the current session', + }, + { + id: 'code-changes', + header: 'diff', + description: 'Lines added/removed in the session (not shown when zero)', + }, + { + id: 'token-count', + header: 'tokens', + description: 'Total tokens used in the session (not shown when zero)', + }, +] as const; + +export type FooterItemId = (typeof ALL_ITEMS)[number]['id']; + +export const DEFAULT_ORDER = [ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'context-used', + 'quota', + 'memory-usage', + 'session-id', + 'code-changes', + 'token-count', +]; + +export function deriveItemsFromLegacySettings( + settings: MergedSettings, +): string[] { + const defaults = [ + 'workspace', + 'git-branch', + 'sandbox', + 'model-name', + 'quota', + ]; + const items = [...defaults]; + + const remove = (arr: string[], id: string) => { + const idx = arr.indexOf(id); + if (idx !== -1) arr.splice(idx, 1); + }; + + if (settings.ui.footer.hideCWD) remove(items, 'workspace'); + if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox'); + if (settings.ui.footer.hideModelInfo) { + remove(items, 'model-name'); + remove(items, 'context-used'); + remove(items, 'quota'); + } + if ( + !settings.ui.footer.hideContextPercentage && + !items.includes('context-used') + ) { + const modelIdx = items.indexOf('model-name'); + if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-used'); + else items.push('context-used'); + } + if (settings.ui.showMemoryUsage) items.push('memory-usage'); + + return items; +} + +const VALID_IDS: Set = new Set(ALL_ITEMS.map((i) => i.id)); + +/** + * Resolves the ordered list and selected set of footer items from settings. + * Used by FooterConfigDialog to initialize and reset state. + */ +export function resolveFooterState(settings: MergedSettings): { + orderedIds: string[]; + selectedIds: Set; +} { + const source = ( + settings.ui?.footer?.items ?? deriveItemsFromLegacySettings(settings) + ).filter((id: string) => VALID_IDS.has(id)); + const others = DEFAULT_ORDER.filter((id) => !source.includes(id)); + return { + orderedIds: [...source, ...others], + selectedIds: new Set(source), + }; +} diff --git a/packages/cli/src/config/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts deleted file mode 100644 index c2abc32d27..0000000000 --- a/packages/cli/src/config/keyBindings.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import type { KeyBindingConfig } from './keyBindings.js'; -import { - Command, - commandCategories, - commandDescriptions, - defaultKeyBindings, -} from './keyBindings.js'; - -describe('keyBindings config', () => { - describe('defaultKeyBindings', () => { - it('should have bindings for all commands', () => { - const commands = Object.values(Command); - - for (const command of commands) { - expect(defaultKeyBindings[command]).toBeDefined(); - expect(Array.isArray(defaultKeyBindings[command])).toBe(true); - expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0); - } - }); - - it('should have valid key binding structures', () => { - for (const [_, bindings] of Object.entries(defaultKeyBindings)) { - for (const binding of bindings) { - // Each binding must have a key name - expect(typeof binding.key).toBe('string'); - expect(binding.key.length).toBeGreaterThan(0); - - // Modifier properties should be boolean or undefined - if (binding.shift !== undefined) { - expect(typeof binding.shift).toBe('boolean'); - } - if (binding.alt !== undefined) { - expect(typeof binding.alt).toBe('boolean'); - } - if (binding.ctrl !== undefined) { - expect(typeof binding.ctrl).toBe('boolean'); - } - if (binding.cmd !== undefined) { - expect(typeof binding.cmd).toBe('boolean'); - } - } - } - }); - - it('should export all required types', () => { - // Basic type checks - expect(typeof Command.HOME).toBe('string'); - expect(typeof Command.END).toBe('string'); - - // Config should be readonly - const config: KeyBindingConfig = defaultKeyBindings; - expect(config[Command.HOME]).toBeDefined(); - }); - - it('should have correct specific bindings', () => { - // Verify navigation ignores shift - const navUp = defaultKeyBindings[Command.NAVIGATION_UP]; - expect(navUp).toContainEqual({ key: 'up', shift: false }); - - const navDown = defaultKeyBindings[Command.NAVIGATION_DOWN]; - expect(navDown).toContainEqual({ key: 'down', shift: false }); - - // Verify dialog navigation - const dialogNavUp = defaultKeyBindings[Command.DIALOG_NAVIGATION_UP]; - expect(dialogNavUp).toContainEqual({ key: 'up', shift: false }); - expect(dialogNavUp).toContainEqual({ key: 'k', shift: false }); - - const dialogNavDown = defaultKeyBindings[Command.DIALOG_NAVIGATION_DOWN]; - expect(dialogNavDown).toContainEqual({ key: 'down', shift: false }); - expect(dialogNavDown).toContainEqual({ key: 'j', shift: false }); - - // Verify physical home/end keys for cursor movement - expect(defaultKeyBindings[Command.HOME]).toContainEqual({ - key: 'home', - ctrl: false, - shift: false, - }); - expect(defaultKeyBindings[Command.END]).toContainEqual({ - key: 'end', - ctrl: false, - shift: false, - }); - - // Verify physical home/end keys for scrolling - expect(defaultKeyBindings[Command.SCROLL_HOME]).toContainEqual({ - key: 'home', - ctrl: true, - }); - expect(defaultKeyBindings[Command.SCROLL_END]).toContainEqual({ - key: 'end', - ctrl: true, - }); - }); - }); - - describe('command metadata', () => { - const commandValues = Object.values(Command); - - it('has a description entry for every command', () => { - const describedCommands = Object.keys(commandDescriptions); - expect(describedCommands.sort()).toEqual([...commandValues].sort()); - - for (const command of commandValues) { - expect(typeof commandDescriptions[command]).toBe('string'); - expect(commandDescriptions[command]?.trim()).not.toHaveLength(0); - } - }); - - it('categorizes each command exactly once', () => { - const seen = new Set(); - - for (const category of commandCategories) { - expect(typeof category.title).toBe('string'); - expect(Array.isArray(category.commands)).toBe(true); - - for (const command of category.commands) { - expect(commandValues).toContain(command); - expect(seen.has(command)).toBe(false); - seen.add(command); - } - } - - expect(seen.size).toBe(commandValues.length); - }); - }); -}); diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts deleted file mode 100644 index 4813abd368..0000000000 --- a/packages/cli/src/config/keyBindings.ts +++ /dev/null @@ -1,528 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Command enum for all available keyboard shortcuts - */ -export enum Command { - // Basic Controls - RETURN = 'basic.confirm', - ESCAPE = 'basic.cancel', - QUIT = 'basic.quit', - EXIT = 'basic.exit', - - // Cursor Movement - HOME = 'cursor.home', - END = 'cursor.end', - MOVE_UP = 'cursor.up', - MOVE_DOWN = 'cursor.down', - MOVE_LEFT = 'cursor.left', - MOVE_RIGHT = 'cursor.right', - MOVE_WORD_LEFT = 'cursor.wordLeft', - MOVE_WORD_RIGHT = 'cursor.wordRight', - - // Editing - KILL_LINE_RIGHT = 'edit.deleteRightAll', - KILL_LINE_LEFT = 'edit.deleteLeftAll', - CLEAR_INPUT = 'edit.clear', - DELETE_WORD_BACKWARD = 'edit.deleteWordLeft', - DELETE_WORD_FORWARD = 'edit.deleteWordRight', - DELETE_CHAR_LEFT = 'edit.deleteLeft', - DELETE_CHAR_RIGHT = 'edit.deleteRight', - UNDO = 'edit.undo', - REDO = 'edit.redo', - - // Scrolling - SCROLL_UP = 'scroll.up', - SCROLL_DOWN = 'scroll.down', - SCROLL_HOME = 'scroll.home', - SCROLL_END = 'scroll.end', - PAGE_UP = 'scroll.pageUp', - PAGE_DOWN = 'scroll.pageDown', - - // History & Search - HISTORY_UP = 'history.previous', - HISTORY_DOWN = 'history.next', - REVERSE_SEARCH = 'history.search.start', - SUBMIT_REVERSE_SEARCH = 'history.search.submit', - ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept', - REWIND = 'history.rewind', - - // Navigation - NAVIGATION_UP = 'nav.up', - NAVIGATION_DOWN = 'nav.down', - DIALOG_NAVIGATION_UP = 'nav.dialog.up', - DIALOG_NAVIGATION_DOWN = 'nav.dialog.down', - DIALOG_NEXT = 'nav.dialog.next', - DIALOG_PREV = 'nav.dialog.previous', - - // Suggestions & Completions - ACCEPT_SUGGESTION = 'suggest.accept', - COMPLETION_UP = 'suggest.focusPrevious', - COMPLETION_DOWN = 'suggest.focusNext', - EXPAND_SUGGESTION = 'suggest.expand', - COLLAPSE_SUGGESTION = 'suggest.collapse', - - // Text Input - SUBMIT = 'input.submit', - NEWLINE = 'input.newline', - OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor', - PASTE_CLIPBOARD = 'input.paste', - - BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape', - BACKGROUND_SHELL_SELECT = 'backgroundShellSelect', - TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell', - TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList', - KILL_BACKGROUND_SHELL = 'backgroundShell.kill', - UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus', - UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus', - SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning', - SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning', - - // App Controls - SHOW_ERROR_DETAILS = 'app.showErrorDetails', - SHOW_FULL_TODOS = 'app.showFullTodos', - SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail', - TOGGLE_MARKDOWN = 'app.toggleMarkdown', - TOGGLE_COPY_MODE = 'app.toggleCopyMode', - TOGGLE_YOLO = 'app.toggleYolo', - CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode', - SHOW_MORE_LINES = 'app.showMoreLines', - EXPAND_PASTE = 'app.expandPaste', - FOCUS_SHELL_INPUT = 'app.focusShellInput', - UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput', - CLEAR_SCREEN = 'app.clearScreen', - RESTART_APP = 'app.restart', - SUSPEND_APP = 'app.suspend', -} - -/** - * Data-driven key binding structure for user configuration - */ -export interface KeyBinding { - /** The key name (e.g., 'a', 'return', 'tab', 'escape') */ - key: string; - /** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - shift?: boolean; - /** Alt/Option key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - alt?: boolean; - /** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - ctrl?: boolean; - /** Command/Windows/Super key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */ - cmd?: boolean; -} - -/** - * Configuration type mapping commands to their key bindings - */ -export type KeyBindingConfig = { - readonly [C in Command]: readonly KeyBinding[]; -}; - -/** - * Default key binding configuration - * Matches the original hard-coded logic exactly - */ -export const defaultKeyBindings: KeyBindingConfig = { - // Basic Controls - [Command.RETURN]: [{ key: 'return' }], - [Command.ESCAPE]: [{ key: 'escape' }, { key: '[', ctrl: true }], - [Command.QUIT]: [{ key: 'c', ctrl: true }], - [Command.EXIT]: [{ key: 'd', ctrl: true }], - - // Cursor Movement - [Command.HOME]: [ - { key: 'a', ctrl: true }, - { key: 'home', shift: false, ctrl: false }, - ], - [Command.END]: [ - { key: 'e', ctrl: true }, - { key: 'end', shift: false, ctrl: false }, - ], - [Command.MOVE_UP]: [ - { key: 'up', shift: false, alt: false, ctrl: false, cmd: false }, - ], - [Command.MOVE_DOWN]: [ - { key: 'down', shift: false, alt: false, ctrl: false, cmd: false }, - ], - [Command.MOVE_LEFT]: [ - { key: 'left', shift: false, alt: false, ctrl: false, cmd: false }, - ], - [Command.MOVE_RIGHT]: [ - { key: 'right', shift: false, alt: false, ctrl: false, cmd: false }, - { key: 'f', ctrl: true }, - ], - [Command.MOVE_WORD_LEFT]: [ - { key: 'left', ctrl: true }, - { key: 'left', alt: true }, - { key: 'b', alt: true }, - ], - [Command.MOVE_WORD_RIGHT]: [ - { key: 'right', ctrl: true }, - { key: 'right', alt: true }, - { key: 'f', alt: true }, - ], - - // Editing - [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], - [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], - [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], - [Command.DELETE_WORD_BACKWARD]: [ - { key: 'backspace', ctrl: true }, - { key: 'backspace', alt: true }, - { key: 'w', ctrl: true }, - ], - [Command.DELETE_WORD_FORWARD]: [ - { key: 'delete', ctrl: true }, - { key: 'delete', alt: true }, - { key: 'd', alt: true }, - ], - [Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }], - [Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }], - [Command.UNDO]: [ - { key: 'z', cmd: true, shift: false }, - { key: 'z', alt: true, shift: false }, - ], - [Command.REDO]: [ - { key: 'z', ctrl: true, shift: true }, - { key: 'z', cmd: true, shift: true }, - { key: 'z', alt: true, shift: true }, - ], - - // Scrolling - [Command.SCROLL_UP]: [{ key: 'up', shift: true }], - [Command.SCROLL_DOWN]: [{ key: 'down', shift: true }], - [Command.SCROLL_HOME]: [ - { key: 'home', ctrl: true }, - { key: 'home', shift: true }, - ], - [Command.SCROLL_END]: [ - { key: 'end', ctrl: true }, - { key: 'end', shift: true }, - ], - [Command.PAGE_UP]: [{ key: 'pageup' }], - [Command.PAGE_DOWN]: [{ key: 'pagedown' }], - - // History & Search - [Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }], - [Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }], - [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], - [Command.REWIND]: [{ key: 'double escape' }], - [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], - [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab', shift: false }], - - // Navigation - [Command.NAVIGATION_UP]: [{ key: 'up', shift: false }], - [Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }], - // Navigation shortcuts appropriate for dialogs where we do not need to accept - // text input. - [Command.DIALOG_NAVIGATION_UP]: [ - { key: 'up', shift: false }, - { key: 'k', shift: false }, - ], - [Command.DIALOG_NAVIGATION_DOWN]: [ - { key: 'down', shift: false }, - { key: 'j', shift: false }, - ], - [Command.DIALOG_NEXT]: [{ key: 'tab', shift: false }], - [Command.DIALOG_PREV]: [{ key: 'tab', shift: true }], - - // Suggestions & Completions - [Command.ACCEPT_SUGGESTION]: [ - { key: 'tab', shift: false }, - { key: 'return', ctrl: false }, - ], - [Command.COMPLETION_UP]: [ - { key: 'up', shift: false }, - { key: 'p', shift: false, ctrl: true }, - ], - [Command.COMPLETION_DOWN]: [ - { key: 'down', shift: false }, - { key: 'n', shift: false, ctrl: true }, - ], - [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], - [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], - - // Text Input - // Must also exclude shift to allow shift+enter for newline - [Command.SUBMIT]: [ - { - key: 'return', - shift: false, - alt: false, - ctrl: false, - cmd: false, - }, - ], - [Command.NEWLINE]: [ - { key: 'return', ctrl: true }, - { key: 'return', cmd: true }, - { key: 'return', alt: true }, - { key: 'return', shift: true }, - { key: 'j', ctrl: true }, - ], - [Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }], - [Command.PASTE_CLIPBOARD]: [ - { key: 'v', ctrl: true }, - { key: 'v', cmd: true }, - { key: 'v', alt: true }, - ], - - // App Controls - [Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }], - [Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }], - [Command.SHOW_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], - [Command.TOGGLE_MARKDOWN]: [{ key: 'm', alt: true }], - [Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }], - [Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }], - [Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }], - [Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }], - [Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }], - [Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }], - [Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }], - [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab', shift: false }], - [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [ - { key: 'tab', shift: false }, - ], - [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }], - [Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }], - [Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }], - [Command.SHOW_MORE_LINES]: [{ key: 'o', ctrl: true }], - [Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }], - [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }], - [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }], - [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], - [Command.RESTART_APP]: [{ key: 'r' }], - [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], -}; - -interface CommandCategory { - readonly title: string; - readonly commands: readonly Command[]; -} - -/** - * Presentation metadata for grouping commands in documentation or UI. - */ -export const commandCategories: readonly CommandCategory[] = [ - { - title: 'Basic Controls', - commands: [Command.RETURN, Command.ESCAPE, Command.QUIT, Command.EXIT], - }, - { - title: 'Cursor Movement', - commands: [ - Command.HOME, - Command.END, - Command.MOVE_UP, - Command.MOVE_DOWN, - Command.MOVE_LEFT, - Command.MOVE_RIGHT, - Command.MOVE_WORD_LEFT, - Command.MOVE_WORD_RIGHT, - ], - }, - { - title: 'Editing', - commands: [ - Command.KILL_LINE_RIGHT, - Command.KILL_LINE_LEFT, - Command.CLEAR_INPUT, - Command.DELETE_WORD_BACKWARD, - Command.DELETE_WORD_FORWARD, - Command.DELETE_CHAR_LEFT, - Command.DELETE_CHAR_RIGHT, - Command.UNDO, - Command.REDO, - ], - }, - { - title: 'Scrolling', - commands: [ - Command.SCROLL_UP, - Command.SCROLL_DOWN, - Command.SCROLL_HOME, - Command.SCROLL_END, - Command.PAGE_UP, - Command.PAGE_DOWN, - ], - }, - { - title: 'History & Search', - commands: [ - Command.HISTORY_UP, - Command.HISTORY_DOWN, - Command.REVERSE_SEARCH, - Command.SUBMIT_REVERSE_SEARCH, - Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, - Command.REWIND, - ], - }, - { - title: 'Navigation', - commands: [ - Command.NAVIGATION_UP, - Command.NAVIGATION_DOWN, - Command.DIALOG_NAVIGATION_UP, - Command.DIALOG_NAVIGATION_DOWN, - Command.DIALOG_NEXT, - Command.DIALOG_PREV, - ], - }, - { - title: 'Suggestions & Completions', - commands: [ - Command.ACCEPT_SUGGESTION, - Command.COMPLETION_UP, - Command.COMPLETION_DOWN, - Command.EXPAND_SUGGESTION, - Command.COLLAPSE_SUGGESTION, - ], - }, - { - title: 'Text Input', - commands: [ - Command.SUBMIT, - Command.NEWLINE, - Command.OPEN_EXTERNAL_EDITOR, - Command.PASTE_CLIPBOARD, - ], - }, - { - title: 'App Controls', - commands: [ - Command.SHOW_ERROR_DETAILS, - Command.SHOW_FULL_TODOS, - Command.SHOW_IDE_CONTEXT_DETAIL, - Command.TOGGLE_MARKDOWN, - Command.TOGGLE_COPY_MODE, - Command.TOGGLE_YOLO, - Command.CYCLE_APPROVAL_MODE, - Command.SHOW_MORE_LINES, - Command.EXPAND_PASTE, - Command.TOGGLE_BACKGROUND_SHELL, - Command.TOGGLE_BACKGROUND_SHELL_LIST, - Command.KILL_BACKGROUND_SHELL, - Command.BACKGROUND_SHELL_SELECT, - Command.BACKGROUND_SHELL_ESCAPE, - Command.UNFOCUS_BACKGROUND_SHELL, - Command.UNFOCUS_BACKGROUND_SHELL_LIST, - Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, - Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, - Command.FOCUS_SHELL_INPUT, - Command.UNFOCUS_SHELL_INPUT, - Command.CLEAR_SCREEN, - Command.RESTART_APP, - Command.SUSPEND_APP, - ], - }, -]; - -/** - * Human-readable descriptions for each command, used in docs/tooling. - */ -export const commandDescriptions: Readonly> = { - // Basic Controls - [Command.RETURN]: 'Confirm the current selection or choice.', - [Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.', - [Command.QUIT]: - 'Cancel the current request or quit the CLI when input is empty.', - [Command.EXIT]: 'Exit the CLI when the input buffer is empty.', - - // Cursor Movement - [Command.HOME]: 'Move the cursor to the start of the line.', - [Command.END]: 'Move the cursor to the end of the line.', - [Command.MOVE_UP]: 'Move the cursor up one line.', - [Command.MOVE_DOWN]: 'Move the cursor down one line.', - [Command.MOVE_LEFT]: 'Move the cursor one character to the left.', - [Command.MOVE_RIGHT]: 'Move the cursor one character to the right.', - [Command.MOVE_WORD_LEFT]: 'Move the cursor one word to the left.', - [Command.MOVE_WORD_RIGHT]: 'Move the cursor one word to the right.', - - // Editing - [Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.', - [Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.', - [Command.CLEAR_INPUT]: 'Clear all text in the input field.', - [Command.DELETE_WORD_BACKWARD]: 'Delete the previous word.', - [Command.DELETE_WORD_FORWARD]: 'Delete the next word.', - [Command.DELETE_CHAR_LEFT]: 'Delete the character to the left.', - [Command.DELETE_CHAR_RIGHT]: 'Delete the character to the right.', - [Command.UNDO]: 'Undo the most recent text edit.', - [Command.REDO]: 'Redo the most recent undone text edit.', - - // Scrolling - [Command.SCROLL_UP]: 'Scroll content up.', - [Command.SCROLL_DOWN]: 'Scroll content down.', - [Command.SCROLL_HOME]: 'Scroll to the top.', - [Command.SCROLL_END]: 'Scroll to the bottom.', - [Command.PAGE_UP]: 'Scroll up by one page.', - [Command.PAGE_DOWN]: 'Scroll down by one page.', - - // History & Search - [Command.HISTORY_UP]: 'Show the previous entry in history.', - [Command.HISTORY_DOWN]: 'Show the next entry in history.', - [Command.REVERSE_SEARCH]: 'Start reverse search through history.', - [Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.', - [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: - 'Accept a suggestion while reverse searching.', - [Command.REWIND]: 'Browse and rewind previous interactions.', - - // Navigation - [Command.NAVIGATION_UP]: 'Move selection up in lists.', - [Command.NAVIGATION_DOWN]: 'Move selection down in lists.', - [Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.', - [Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.', - [Command.DIALOG_NEXT]: 'Move to the next item or question in a dialog.', - [Command.DIALOG_PREV]: 'Move to the previous item or question in a dialog.', - - // Suggestions & Completions - [Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.', - [Command.COMPLETION_UP]: 'Move to the previous completion option.', - [Command.COMPLETION_DOWN]: 'Move to the next completion option.', - [Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.', - [Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.', - - // Text Input - [Command.SUBMIT]: 'Submit the current prompt.', - [Command.NEWLINE]: 'Insert a newline without submitting.', - [Command.OPEN_EXTERNAL_EDITOR]: - 'Open the current prompt in an external editor.', - [Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.', - - // App Controls - [Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.', - [Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.', - [Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.', - [Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.', - [Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.', - [Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.', - [Command.CYCLE_APPROVAL_MODE]: - 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.', - [Command.SHOW_MORE_LINES]: - 'Expand and collapse blocks of content when not in alternate buffer mode.', - [Command.EXPAND_PASTE]: - 'Expand or collapse a paste placeholder when cursor is over placeholder.', - [Command.BACKGROUND_SHELL_SELECT]: - 'Confirm selection in background shell list.', - [Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.', - [Command.TOGGLE_BACKGROUND_SHELL]: - 'Toggle current background shell visibility.', - [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.', - [Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.', - [Command.UNFOCUS_BACKGROUND_SHELL]: - 'Move focus from background shell to Gemini.', - [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: - 'Move focus from background shell list to Gemini.', - [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: - 'Show warning when trying to move focus away from background shell.', - [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: - 'Show warning when trying to move focus away from shell input.', - [Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.', - [Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.', - [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', - [Command.RESTART_APP]: 'Restart the application.', - [Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.', -}; diff --git a/packages/cli/src/config/mcp/mcpServerEnablement.test.ts b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts index 8b41324790..12b483d59d 100644 --- a/packages/cli/src/config/mcp/mcpServerEnablement.test.ts +++ b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts @@ -13,6 +13,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, Storage: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...actual.Storage, getGlobalGeminiDir: () => '/virtual-home/.gemini', }, diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 1d7573337e..2e74a28201 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ApprovalMode, PolicyDecision, @@ -29,6 +29,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); describe('Policy Engine Integration Tests', () => { + beforeEach(() => vi.stubEnv('GEMINI_SYSTEM_MD', '')); + + afterEach(() => vi.unstubAllEnvs()); + describe('Policy configuration produces valid PolicyEngine config', () => { it('should create a working PolicyEngine from basic settings', async () => { const settings: Settings = { @@ -89,13 +93,13 @@ describe('Policy Engine Integration Tests', () => { // Tools from allowed server should be allowed // Tools from allowed server should be allowed expect( - (await engine.check({ name: 'allowed-server__tool1' }, undefined)) + (await engine.check({ name: 'mcp_allowed-server_tool1' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( ( await engine.check( - { name: 'allowed-server__another_tool' }, + { name: 'mcp_allowed-server_another_tool' }, undefined, ) ).decision, @@ -103,13 +107,13 @@ describe('Policy Engine Integration Tests', () => { // Tools from trusted server should be allowed expect( - (await engine.check({ name: 'trusted-server__tool1' }, undefined)) + (await engine.check({ name: 'mcp_trusted-server_tool1' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( ( await engine.check( - { name: 'trusted-server__special_tool' }, + { name: 'mcp_trusted-server_special_tool' }, undefined, ) ).decision, @@ -117,17 +121,17 @@ describe('Policy Engine Integration Tests', () => { // Tools from blocked server should be denied expect( - (await engine.check({ name: 'blocked-server__tool1' }, undefined)) + (await engine.check({ name: 'mcp_blocked-server_tool1' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); expect( - (await engine.check({ name: 'blocked-server__any_tool' }, undefined)) + (await engine.check({ name: 'mcp_blocked-server_any_tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); // Tools from unknown servers should use default expect( - (await engine.check({ name: 'unknown-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_unknown-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.ASK_USER); }); @@ -147,12 +151,16 @@ describe('Policy Engine Integration Tests', () => { // ANY tool with a server name should be allowed expect( - (await engine.check({ name: 'mcp-server__tool' }, 'mcp-server')) + (await engine.check({ name: 'mcp_mcp-server_tool' }, 'mcp-server')) .decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'another-server__tool' }, 'another-server')) - .decision, + ( + await engine.check( + { name: 'mcp_another-server_tool' }, + 'another-server', + ) + ).decision, ).toBe(PolicyDecision.ALLOW); // Built-in tools should NOT be allowed by the MCP wildcard @@ -167,7 +175,7 @@ describe('Policy Engine Integration Tests', () => { allowed: ['my-server'], }, tools: { - exclude: ['my-server__dangerous-tool'], + exclude: ['mcp_my-server_dangerous-tool'], }, }; @@ -177,23 +185,27 @@ describe('Policy Engine Integration Tests', () => { ); const engine = new PolicyEngine(config); - // MCP server allowed (priority 3.1) provides general allow for server - // MCP server allowed (priority 3.1) provides general allow for server + // MCP server allowed (priority 4.1) provides general allow for server + // MCP server allowed (priority 4.1) provides general allow for server expect( - (await engine.check({ name: 'my-server__safe-tool' }, undefined)) + (await engine.check({ name: 'mcp_my-server_safe-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); - // But specific tool exclude (priority 3.4) wins over server allow + // But specific tool exclude (priority 4.4) wins over server allow expect( - (await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) - .decision, + ( + await engine.check( + { name: 'mcp_my-server_dangerous-tool' }, + undefined, + ) + ).decision, ).toBe(PolicyDecision.DENY); }); it('should handle complex mixed configurations', async () => { const settings: Settings = { tools: { - allowed: ['custom-tool', 'my-server__special-tool'], + allowed: ['custom-tool', 'mcp_my-server_special-tool'], exclude: ['glob', 'dangerous-tool'], }, mcp: { @@ -238,21 +250,21 @@ describe('Policy Engine Integration Tests', () => { (await engine.check({ name: 'custom-tool' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'my-server__special-tool' }, undefined)) + (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); // MCP server tools expect( - (await engine.check({ name: 'allowed-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_allowed-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'trusted-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_trusted-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'blocked-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_blocked-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); @@ -334,6 +346,12 @@ describe('Policy Engine Integration Tests', () => { expect( (await engine.check({ name: 'list_directory' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'get_internal_docs' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'cli_help' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); // Other tools should be denied via catch all expect( @@ -476,27 +494,31 @@ describe('Policy Engine Integration Tests', () => { // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); - expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude + expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude const blockedServerRule = rules.find( - (r) => r.toolName === 'blocked-server__*', + (r) => r.toolName === 'mcp_blocked-server_*', ); - expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude + expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); - expect(specificToolRule?.priority).toBe(3.3); // Command line allow + expect(specificToolRule?.priority).toBe(4.3); // Command line allow const trustedServerRule = rules.find( - (r) => r.toolName === 'trusted-server__*', + (r) => r.toolName === 'mcp_trusted-server_*', ); - expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server + expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server - const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); - expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server + const mcpServerRule = rules.find( + (r) => r.toolName === 'mcp_mcp-server_*', + ); + expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server - const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); + const readOnlyToolRule = rules.find( + (r) => r.toolName === 'glob' && !r.subagent, + ); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5); @@ -505,18 +527,19 @@ describe('Policy Engine Integration Tests', () => { (await engine.check({ name: 'blocked-tool' }, undefined)).decision, ).toBe(PolicyDecision.DENY); expect( - (await engine.check({ name: 'blocked-server__any' }, undefined)) + (await engine.check({ name: 'mcp_blocked-server_any' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); expect( (await engine.check({ name: 'specific-tool' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'trusted-server__any' }, undefined)) + (await engine.check({ name: 'mcp_trusted-server_any' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'mcp-server__any' }, undefined)).decision, + (await engine.check({ name: 'mcp_mcp-server_any' }, undefined)) + .decision, ).toBe(PolicyDecision.ALLOW); expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe( PolicyDecision.ALLOW, @@ -545,7 +568,7 @@ describe('Policy Engine Integration Tests', () => { // Exclusion (195) should win over trust (90) expect( - (await engine.check({ name: 'conflicted-server__tool' }, undefined)) + (await engine.check({ name: 'mcp_conflicted-server_tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); }); @@ -556,7 +579,7 @@ describe('Policy Engine Integration Tests', () => { excluded: ['my-server'], // Priority 195 - DENY }, tools: { - allowed: ['my-server__special-tool'], // Priority 100 - ALLOW + allowed: ['mcp_my-server_special-tool'], // Priority 100 - ALLOW }, }; @@ -569,11 +592,11 @@ describe('Policy Engine Integration Tests', () => { // Server exclusion (195) wins over specific tool allow (100) // This might be counterintuitive but follows the priority system expect( - (await engine.check({ name: 'my-server__special-tool' }, undefined)) + (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); expect( - (await engine.check({ name: 'my-server__other-tool' }, undefined)) + (await engine.check({ name: 'mcp_my-server_other-tool' }, undefined)) .decision, ).toBe(PolicyDecision.DENY); }); @@ -641,18 +664,18 @@ describe('Policy Engine Integration Tests', () => { // Verify each rule has the expected priority const tool3Rule = rules.find((r) => r.toolName === 'tool3'); - expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier) + expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier) - const server2Rule = rules.find((r) => r.toolName === 'server2__*'); - expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier) + const server2Rule = rules.find((r) => r.toolName === 'mcp_server2_*'); + expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); - expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier) + expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier) - const server1Rule = rules.find((r) => r.toolName === 'server1__*'); - expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier) + const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*'); + expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) - const globRule = rules.find((r) => r.toolName === 'glob'); + const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent); // Priority 70 in default tier → 1.07 expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index 10d53e56ef..8d368bfb91 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -12,6 +12,8 @@ import { resolveWorkspacePolicyState, autoAcceptWorkspacePolicies, setAutoAcceptWorkspacePolicies, + disableWorkspacePolicies, + setDisableWorkspacePolicies, } from './policy.js'; import { writeToStderr } from '@google/gemini-cli-core'; @@ -45,6 +47,9 @@ describe('resolveWorkspacePolicyState', () => { fs.mkdirSync(workspaceDir); policiesDir = path.join(workspaceDir, '.gemini', 'policies'); + // Enable policies for these tests to verify loading logic + setDisableWorkspacePolicies(false); + vi.clearAllMocks(); }); @@ -67,6 +72,13 @@ describe('resolveWorkspacePolicyState', () => { }); }); + it('should have disableWorkspacePolicies set to true by default', () => { + // We explicitly set it to false in beforeEach for other tests, + // so here we test that setting it to true works. + setDisableWorkspacePolicies(true); + expect(disableWorkspacePolicies).toBe(true); + }); + it('should return policy directory if integrity matches', async () => { // Set up policies directory with a file fs.mkdirSync(policiesDir, { recursive: true }); @@ -171,7 +183,6 @@ describe('resolveWorkspacePolicyState', () => { setAutoAcceptWorkspacePolicies(originalValue); } }); - it('should not return workspace policies if cwd is the home directory', async () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); @@ -188,7 +199,26 @@ describe('resolveWorkspacePolicyState', () => { expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); - it('should not return workspace policies if cwd is a symlink to the home directory', async () => { + it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => { + setDisableWorkspacePolicies(true); + + // Set up policies directory with a file + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result).toEqual({ + workspacePoliciesDir: undefined, + policyUpdateConfirmationRequest: undefined, + }); + }); + + it('should return empty state if cwd is a symlink to the home directory', async () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 6ce44020f5..9837c2c355 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -35,6 +35,20 @@ export function setAutoAcceptWorkspacePolicies(value: boolean) { autoAcceptWorkspacePolicies = value; } +/** + * Temporary flag to disable workspace level policies altogether. + * Exported as 'let' to allow monkey patching in tests via the setter. + */ +export let disableWorkspacePolicies = true; + +/** + * Sets the disableWorkspacePolicies flag. + * Used primarily for testing purposes. + */ +export function setDisableWorkspacePolicies(value: boolean) { + disableWorkspacePolicies = value; +} + export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, @@ -47,7 +61,11 @@ export async function createPolicyEngineConfig( tools: settings.tools, mcpServers: settings.mcpServers, policyPaths: settings.policyPaths, + adminPolicyPaths: settings.adminPolicyPaths, workspacePoliciesDir, + disableAlwaysAllow: + settings.security?.disableAlwaysAllow || + settings.admin?.secureModeEnabled, }; return createCorePolicyEngineConfig(policySettings, approvalMode); @@ -81,7 +99,7 @@ export async function resolveWorkspacePolicyState(options: { | PolicyUpdateConfirmationRequest | undefined; - if (trustedFolder) { + if (trustedFolder && !disableWorkspacePolicies) { const storage = new Storage(cwd); // If we are in the home directory (or rather, our target Gemini dir is the global one), diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index 14080dc30b..3ec0e6a5bb 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -90,14 +90,20 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); it('should throw if GEMINI_SANDBOX is an invalid command', async () => { process.env['GEMINI_SANDBOX'] = 'invalid-command'; await expect(loadSandboxConfig({}, {})).rejects.toThrow( - "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec", + "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc", ); }); @@ -108,6 +114,28 @@ describe('loadSandboxConfig', () => { "Missing sandbox command 'docker' (from GEMINI_SANDBOX)", ); }); + + it('should use lxc if GEMINI_SANDBOX=lxc and it exists', async () => { + process.env['GEMINI_SANDBOX'] = 'lxc'; + mockedCommandExistsSync.mockReturnValue(true); + const config = await loadSandboxConfig({}, {}); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'lxc', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc'); + }); + + it('should throw if GEMINI_SANDBOX=lxc but lxc command does not exist', async () => { + process.env['GEMINI_SANDBOX'] = 'lxc'; + mockedCommandExistsSync.mockReturnValue(false); + await expect(loadSandboxConfig({}, {})).rejects.toThrow( + "Missing sandbox command 'lxc' (from GEMINI_SANDBOX)", + ); + }); }); describe('with sandbox: true', () => { @@ -118,6 +146,9 @@ describe('loadSandboxConfig', () => { ); const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, command: 'sandbox-exec', image: 'default/image', }); @@ -128,6 +159,9 @@ describe('loadSandboxConfig', () => { mockedCommandExistsSync.mockReturnValue(true); // all commands exist const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, command: 'sandbox-exec', image: 'default/image', }); @@ -137,14 +171,26 @@ describe('loadSandboxConfig', () => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); const config = await loadSandboxConfig({ tools: { sandbox: true } }, {}); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); }); it('should use podman if available and docker is not', async () => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman'); const config = await loadSandboxConfig({}, { sandbox: true }); - expect(config).toEqual({ command: 'podman', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'podman', + image: 'default/image', + }); }); it('should throw if sandbox: true but no command is found', async () => { @@ -161,7 +207,13 @@ describe('loadSandboxConfig', () => { it('should use the specified command if it exists', async () => { mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, { sandbox: 'podman' }); - expect(config).toEqual({ command: 'podman', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'podman', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman'); }); @@ -178,7 +230,7 @@ describe('loadSandboxConfig', () => { await expect( loadSandboxConfig({}, { sandbox: 'invalid-command' }), ).rejects.toThrow( - "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec", + "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc", ); }); }); @@ -189,14 +241,26 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'docker', image: 'env/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'env/image', + }); }); it('should use image from package.json if env var is not set', async () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); }); it('should return undefined if command is found but no image is configured', async () => { @@ -218,17 +282,231 @@ describe('loadSandboxConfig', () => { 'should enable sandbox for value: %s', async (value) => { const config = await loadSandboxConfig({}, { sandbox: value }); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); }, ); it.each([false, 'false', '0', undefined, null, ''])( 'should disable sandbox for value: %s', async (value) => { - // \`null\` is not a valid type for the arg, but good to test falsiness + // `null` is not a valid type for the arg, but good to test falsiness const config = await loadSandboxConfig({}, { sandbox: value }); expect(config).toBeUndefined(); }, ); }); + + describe('with SandboxConfig object in settings', () => { + beforeEach(() => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); + }); + + it('should support object structure with enabled: true', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + allowedPaths: ['/tmp'], + networkAccess: true, + }, + }, + }, + {}, + ); + expect(config).toEqual({ + enabled: true, + allowedPaths: ['/tmp'], + networkAccess: true, + command: 'docker', + image: 'default/image', + }); + }); + + it('should support object structure with explicit command', async () => { + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman'); + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + command: 'podman', + allowedPaths: [], + networkAccess: false, + }, + }, + }, + {}, + ); + expect(config?.command).toBe('podman'); + }); + + it('should support object structure with custom image', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + image: 'custom/image', + allowedPaths: [], + networkAccess: false, + }, + }, + }, + {}, + ); + expect(config?.image).toBe('custom/image'); + }); + + it('should return undefined if enabled is false in object', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: false, + allowedPaths: [], + networkAccess: false, + }, + }, + }, + {}, + ); + expect(config).toBeUndefined(); + }); + + it('should prioritize CLI flag over settings object', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + allowedPaths: ['/settings-path'], + networkAccess: false, + }, + }, + }, + { sandbox: false }, + ); + expect(config).toBeUndefined(); + }); + }); + + describe('with sandbox: runsc (gVisor)', () => { + beforeEach(() => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockReturnValue(true); + }); + + it('should use runsc via CLI argument on Linux', async () => { + const config = await loadSandboxConfig({}, { sandbox: 'runsc' }); + + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should use runsc via GEMINI_SANDBOX environment variable', async () => { + process.env['GEMINI_SANDBOX'] = 'runsc'; + const config = await loadSandboxConfig({}, {}); + + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should use runsc via settings file', async () => { + const config = await loadSandboxConfig( + { tools: { sandbox: 'runsc' } }, + {}, + ); + + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should prioritize GEMINI_SANDBOX over CLI and settings', async () => { + process.env['GEMINI_SANDBOX'] = 'runsc'; + const config = await loadSandboxConfig( + { tools: { sandbox: 'docker' } }, + { sandbox: 'podman' }, + ); + + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); + }); + + it('should reject runsc on macOS (Linux-only)', async () => { + mockedOsPlatform.mockReturnValue('darwin'); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + 'gVisor (runsc) sandboxing is only supported on Linux', + ); + }); + + it('should reject runsc on Windows (Linux-only)', async () => { + mockedOsPlatform.mockReturnValue('win32'); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + 'gVisor (runsc) sandboxing is only supported on Linux', + ); + }); + + it('should throw if runsc binary not found', async () => { + mockedCommandExistsSync.mockReturnValue(false); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + "Missing sandbox command 'runsc' (from GEMINI_SANDBOX)", + ); + }); + + it('should throw if Docker not available (runsc requires Docker)', async () => { + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'runsc'); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.", + ); + }); + + it('should NOT auto-detect runsc when both runsc and docker available', async () => { + mockedCommandExistsSync.mockImplementation( + (cmd) => cmd === 'runsc' || cmd === 'docker', + ); + + const config = await loadSandboxConfig({}, { sandbox: true }); + + expect(config?.command).toBe('docker'); + expect(config?.command).not.toBe('runsc'); + }); + }); }); diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index e1b7305772..1a047760d3 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -23,14 +23,21 @@ const __dirname = path.dirname(__filename); interface SandboxCliArgs { sandbox?: boolean | string | null; } -const VALID_SANDBOX_COMMANDS: ReadonlyArray = [ +const VALID_SANDBOX_COMMANDS = [ 'docker', 'podman', 'sandbox-exec', + 'runsc', + 'lxc', + 'windows-native', ]; -function isSandboxCommand(value: string): value is SandboxConfig['command'] { - return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value); +function isSandboxCommand( + value: string, +): value is Exclude { + return (VALID_SANDBOX_COMMANDS as ReadonlyArray).includes( + value, + ); } function getSandboxCommand( @@ -63,17 +70,37 @@ function getSandboxCommand( )}`, ); } - // confirm that specified command exists - if (commandExists.sync(sandbox)) { - return sandbox; + // runsc (gVisor) is only supported on Linux + if (sandbox === 'runsc' && os.platform() !== 'linux') { + throw new FatalSandboxError( + 'gVisor (runsc) sandboxing is only supported on Linux', + ); } - throw new FatalSandboxError( - `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, - ); + // windows-native is only supported on Windows + if (sandbox === 'windows-native' && os.platform() !== 'win32') { + throw new FatalSandboxError( + 'Windows native sandboxing is only supported on Windows', + ); + } + + // confirm that specified command exists (unless it's built-in) + if (sandbox !== 'windows-native' && !commandExists.sync(sandbox)) { + throw new FatalSandboxError( + `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, + ); + } + // runsc uses Docker with --runtime=runsc; both must be available (prioritize runsc when explicitly chosen) + if (sandbox === 'runsc' && !commandExists.sync('docker')) { + throw new FatalSandboxError( + "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.", + ); + } + return sandbox; } // look for seatbelt, docker, or podman, in that order // for container-based sandboxing, require sandbox to be enabled explicitly + // note: runsc is NOT auto-detected, it must be explicitly specified if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) { return 'sandbox-exec'; } else if (commandExists.sync('docker') && sandbox === true) { @@ -91,6 +118,9 @@ function getSandboxCommand( } return ''; + // Note: 'lxc' is intentionally not auto-detected because it requires a + // pre-existing, running container managed by the user. Use + // GEMINI_SANDBOX=lxc or sandbox: "lxc" in settings to enable it. } export async function loadSandboxConfig( @@ -98,11 +128,41 @@ export async function loadSandboxConfig( argv: SandboxCliArgs, ): Promise { const sandboxOption = argv.sandbox ?? settings.tools?.sandbox; - const command = getSandboxCommand(sandboxOption); + + let sandboxValue: boolean | string | null | undefined; + let allowedPaths: string[] = []; + let networkAccess = false; + let customImage: string | undefined; + + if ( + typeof sandboxOption === 'object' && + sandboxOption !== null && + !Array.isArray(sandboxOption) + ) { + const config = sandboxOption; + sandboxValue = config.enabled ? (config.command ?? true) : false; + allowedPaths = config.allowedPaths ?? []; + networkAccess = config.networkAccess ?? false; + customImage = config.image; + } else if (typeof sandboxOption !== 'object' || sandboxOption === null) { + sandboxValue = sandboxOption; + } + + const command = getSandboxCommand(sandboxValue); const packageJson = await getPackageJson(__dirname); const image = - process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri; + process.env['GEMINI_SANDBOX_IMAGE'] ?? + process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ?? + customImage ?? + packageJson?.config?.sandboxImageUri; - return command && image ? { command, image } : undefined; + const isNative = + command === 'windows-native' || + command === 'sandbox-exec' || + command === 'lxc'; + + return command && (image || isNative) + ? { enabled: true, allowedPaths, networkAccess, command, image } + : undefined; } diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 8fd0bd81b0..a58b9889a2 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -13,7 +13,7 @@ vi.mock('os', async (importOriginal) => { const actualOs = await importOriginal(); return { ...actualOs, - homedir: vi.fn(() => '/mock/home/user'), + homedir: vi.fn(() => path.resolve('/mock/home/user')), platform: vi.fn(() => 'linux'), }; }); @@ -76,6 +76,7 @@ import { LoadedSettings, sanitizeEnvVar, createTestMergedSettings, + resetSettingsCacheForTesting, } from './settings.js'; import { FatalConfigError, @@ -91,7 +92,7 @@ import { } from './settingsSchema.js'; import { createMockSettings } from '../test-utils/settings.js'; -const MOCK_WORKSPACE_DIR = '/mock/workspace'; +const MOCK_WORKSPACE_DIR = path.resolve(path.resolve('/mock/workspace')); // Use the (mocked) GEMINI_DIR for consistency const MOCK_WORKSPACE_SETTINGS_PATH = path.join( MOCK_WORKSPACE_DIR, @@ -102,6 +103,10 @@ const MOCK_WORKSPACE_SETTINGS_PATH = path.join( // A more flexible type for test data that allows arbitrary properties. type TestSettings = Settings & { [key: string]: unknown }; +// Helper to normalize paths for test assertions, making them OS-agnostic +const normalizePath = (p: string | fs.PathOrFileDescriptor) => + path.normalize(p.toString()); + vi.mock('fs', async (importOriginal) => { // Get all the functions from the real 'fs' module const actualFs = await importOriginal(); @@ -174,12 +179,15 @@ describe('Settings Loading and Merging', () => { beforeEach(() => { vi.resetAllMocks(); + resetSettingsCacheForTesting(); mockFsExistsSync = vi.mocked(fs.existsSync); mockFsMkdirSync = vi.mocked(fs.mkdirSync); mockStripJsonComments = vi.mocked(stripJsonComments); - vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user'); + vi.mocked(osActual.homedir).mockReturnValue( + path.resolve('/mock/home/user'), + ); (mockStripJsonComments as unknown as Mock).mockImplementation( (jsonString: string) => jsonString, ); @@ -224,20 +232,25 @@ describe('Settings Loading and Merging', () => { }, ])( 'should load $scope settings if only $scope file exists', - ({ scope, path, content }) => { + ({ scope, path: p, content }) => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === path, + (pathLike: fs.PathLike) => + path.normalize(pathLike.toString()) === path.normalize(p), ); (fs.readFileSync as Mock).mockImplementation( - (p: fs.PathOrFileDescriptor) => { - if (p === path) return JSON.stringify(content); + (pathDesc: fs.PathOrFileDescriptor) => { + if (path.normalize(pathDesc.toString()) === path.normalize(p)) + return JSON.stringify(content); return '{}'; }, ); const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(fs.readFileSync).toHaveBeenCalledWith(path, 'utf-8'); + expect(fs.readFileSync).toHaveBeenCalledWith( + expect.stringContaining(path.basename(p)), + 'utf-8', + ); expect( settings[scope as 'system' | 'user' | 'workspace'].settings, ).toEqual(content); @@ -246,12 +259,14 @@ describe('Settings Loading and Merging', () => { ); it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => { - (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => - p === getSystemSettingsPath() || - p === USER_SETTINGS_PATH || - p === MOCK_WORKSPACE_SETTINGS_PATH, - ); + (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => { + const normP = path.normalize(p.toString()); + return ( + normP === path.normalize(getSystemSettingsPath()) || + normP === path.normalize(USER_SETTINGS_PATH) || + normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH) + ); + }); const systemSettingsContent = { ui: { theme: 'system-theme', @@ -290,11 +305,12 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + const normP = path.normalize(p.toString()); + if (normP === path.normalize(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normP === path.normalize(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -390,13 +406,13 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemDefaultsPath()) + if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) return JSON.stringify(systemDefaultsContent); - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -449,11 +465,11 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -489,11 +505,11 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -508,26 +524,29 @@ describe('Settings Loading and Merging', () => { const userSettingsContent = { security: { disableYoloMode: false, + disableAlwaysAllow: false, }, }; const workspaceSettingsContent = { security: { disableYoloMode: false, // This should be ignored + disableAlwaysAllow: false, // This should be ignored }, }; const systemSettingsContent = { security: { disableYoloMode: true, + disableAlwaysAllow: true, }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -535,6 +554,7 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used + expect(settings.merged.security?.disableAlwaysAllow).toBe(true); // System setting should be used }); it.each([ @@ -576,11 +596,12 @@ describe('Settings Loading and Merging', () => { 'should handle $description correctly', ({ path, content, expected }) => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === path, + (p: fs.PathLike) => normalizePath(p) === normalizePath(path), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === path) return JSON.stringify(content); + if (normalizePath(p) === normalizePath(path)) + return JSON.stringify(content); return '{}'; }, ); @@ -598,7 +619,8 @@ describe('Settings Loading and Merging', () => { it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => - p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, + normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); const userSettingsContent = { general: {}, @@ -611,9 +633,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -643,15 +665,16 @@ describe('Settings Loading and Merging', () => { it('should default contextFileName to undefined if not in any settings file', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => - p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, + normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); const userSettingsContent = { ui: { theme: 'dark' } }; const workspaceSettingsContent = { tools: { sandbox: true } }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -678,11 +701,12 @@ describe('Settings Loading and Merging', () => { 'should load telemetry setting from $scope settings', ({ path, content, expected }) => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === path, + (p: fs.PathLike) => normalizePath(p) === normalizePath(path), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === path) return JSON.stringify(content); + if (normalizePath(p) === normalizePath(path)) + return JSON.stringify(content); return '{}'; }, ); @@ -697,9 +721,9 @@ describe('Settings Loading and Merging', () => { const workspaceSettingsContent = { telemetry: { enabled: false } }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -720,7 +744,8 @@ describe('Settings Loading and Merging', () => { it('should merge MCP servers correctly, with workspace taking precedence', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => - p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH, + normalizePath(p) === normalizePath(USER_SETTINGS_PATH) || + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); const userSettingsContent = { mcpServers: { @@ -751,9 +776,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return ''; }, @@ -822,11 +847,12 @@ describe('Settings Loading and Merging', () => { 'should handle MCP servers when only in $scope settings', ({ path, content, expected }) => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === path, + (p: fs.PathLike) => normalizePath(p) === normalizePath(path), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === path) return JSON.stringify(content); + if (normalizePath(p) === normalizePath(path)) + return JSON.stringify(content); return '{}'; }, ); @@ -881,11 +907,11 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -932,11 +958,11 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -983,8 +1009,11 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) return JSON.stringify(userContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) + return JSON.stringify(userContent); + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) return JSON.stringify(workspaceContent); return '{}'; }, @@ -1008,9 +1037,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1038,13 +1067,13 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) return JSON.stringify(systemSettingsContent); - if (p === getSystemDefaultsPath()) + if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) return JSON.stringify(systemDefaultsContent); - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1073,14 +1102,16 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) { // Simulate JSON.parse throwing for user settings vi.spyOn(JSON, 'parse').mockImplementationOnce(() => { throw userReadError; }); return invalidJsonContent; // Content that would cause JSON.parse to throw } - if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) { // Simulate JSON.parse throwing for workspace settings vi.spyOn(JSON, 'parse').mockImplementationOnce(() => { throw workspaceReadError; @@ -1119,11 +1150,12 @@ describe('Settings Loading and Merging', () => { someUrl: 'https://test.com/${TEST_API_KEY}', }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1149,11 +1181,12 @@ describe('Settings Loading and Merging', () => { nested: { value: '$WORKSPACE_ENDPOINT' }, }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1201,13 +1234,15 @@ describe('Settings Loading and Merging', () => { (mockFsExistsSync as Mock).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } - if (p === USER_SETTINGS_PATH) { + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) { return JSON.stringify(userSettingsContent); } - if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) { return JSON.stringify(workspaceSettingsContent); } return '{}'; @@ -1266,9 +1301,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1280,14 +1315,15 @@ describe('Settings Loading and Merging', () => { it('should use user dnsResolutionOrder if workspace is not defined', () => { (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); const userSettingsContent = { advanced: { dnsResolutionOrder: 'verbatim' }, }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1300,11 +1336,12 @@ describe('Settings Loading and Merging', () => { it('should leave unresolved environment variables as is', () => { const userSettingsContent: TestSettings = { apiKey: '$UNDEFINED_VAR' }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1326,11 +1363,12 @@ describe('Settings Loading and Merging', () => { path: '/path/$VAR_A/${VAR_B}/end', }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1350,11 +1388,12 @@ describe('Settings Loading and Merging', () => { list: ['$ITEM_1', '${ITEM_2}', 'literal'], }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1389,11 +1428,12 @@ describe('Settings Loading and Merging', () => { }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1434,11 +1474,12 @@ describe('Settings Loading and Merging', () => { serverAddress: '${TEST_HOST}:${TEST_PORT}/api', }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1454,7 +1495,9 @@ describe('Settings Loading and Merging', () => { }); describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => { - const MOCK_ENV_SYSTEM_SETTINGS_PATH = '/mock/env/system/settings.json'; + const MOCK_ENV_SYSTEM_SETTINGS_PATH = path.resolve( + '/mock/env/system/settings.json', + ); beforeEach(() => { process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = @@ -1496,8 +1539,8 @@ describe('Settings Loading and Merging', () => { }); it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => { - const mockHomeDir = '/mock/home/user'; - const mockSymlinkDir = '/mock/symlink/to/home'; + const mockHomeDir = path.resolve('/mock/home/user'); + const mockSymlinkDir = path.resolve('/mock/symlink/to/home'); const mockWorkspaceSettingsPath = path.join( mockSymlinkDir, GEMINI_DIR, @@ -1541,6 +1584,79 @@ describe('Settings Loading and Merging', () => { isWorkspaceHomeDirSpy.mockRestore(); } }); + + describe('caching', () => { + it('should cache loadSettings results', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const settings1 = loadSettings(MOCK_WORKSPACE_DIR); + const settings2 = loadSettings(MOCK_WORKSPACE_DIR); + + expect(mockedRead).toHaveBeenCalledTimes(5); // system, systemDefaults, user, workspace, and potentially an env file + expect(settings1).toBe(settings2); + }); + + it('should use separate cache for different workspace directories', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const workspace1 = path.resolve('/mock/workspace1'); + const workspace2 = path.resolve('/mock/workspace2'); + + const settings1 = loadSettings(workspace1); + const settings2 = loadSettings(workspace2); + + expect(mockedRead).toHaveBeenCalledTimes(10); // 5 for each workspace + expect(settings1).not.toBe(settings2); + }); + + it('should clear cache when saveSettings is called for user settings', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const settings1 = loadSettings(MOCK_WORKSPACE_DIR); + expect(mockedRead).toHaveBeenCalledTimes(5); + + saveSettings(settings1.user); + + const settings2 = loadSettings(MOCK_WORKSPACE_DIR); + expect(mockedRead).toHaveBeenCalledTimes(10); // Should have re-read from disk + expect(settings1).not.toBe(settings2); + }); + + it('should clear all caches when saveSettings is called for workspace settings', () => { + const mockedRead = vi.mocked(fs.readFileSync); + mockedRead.mockClear(); + mockedRead.mockReturnValue('{}'); + (mockFsExistsSync as Mock).mockReturnValue(true); + + const workspace1 = path.resolve('/mock/workspace1'); + const workspace2 = path.resolve('/mock/workspace2'); + + const settings1W1 = loadSettings(workspace1); + const settings1W2 = loadSettings(workspace2); + + expect(mockedRead).toHaveBeenCalledTimes(10); + + // Save settings for workspace 1 + saveSettings(settings1W1.workspace); + + const settings2W1 = loadSettings(workspace1); + const settings2W2 = loadSettings(workspace2); + + // Both workspace caches should have been cleared and re-read from disk (+10 reads) + expect(mockedRead).toHaveBeenCalledTimes(20); + expect(settings1W1).not.toBe(settings2W1); + expect(settings1W2).not.toBe(settings2W2); + }); + }); }); describe('excludedProjectEnvVars integration', () => { @@ -1562,12 +1678,13 @@ describe('Settings Loading and Merging', () => { }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1578,16 +1695,18 @@ describe('Settings Loading and Merging', () => { loadSettings as unknown as { findEnvFile: () => string } ).findEnvFile; (loadSettings as unknown as { findEnvFile: () => string }).findEnvFile = - () => '/mock/project/.env'; + () => path.resolve('/mock/project/.env'); // Mock fs.readFileSync for .env file content const originalReadFileSync = fs.readFileSync; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === '/mock/project/.env') { + if (p === path.resolve('/mock/project/.env')) { return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key'; } - if (p === MOCK_WORKSPACE_SETTINGS_PATH) { + if ( + normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH) + ) { return JSON.stringify(workspaceSettingsContent); } return '{}'; @@ -1621,12 +1740,13 @@ describe('Settings Loading and Merging', () => { }; (mockFsExistsSync as Mock).mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1658,9 +1778,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1702,9 +1822,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1734,9 +1854,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1767,9 +1887,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1940,9 +2060,9 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); - if (p === MOCK_WORKSPACE_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)) return JSON.stringify(workspaceSettingsContent); return '{}'; }, @@ -1966,7 +2086,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -1994,7 +2114,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -2039,7 +2159,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -2162,7 +2282,7 @@ describe('Settings Loading and Merging', () => { } }); - it('should prioritize new settings over deprecated ones and respect removeDeprecated flag', () => { + it('should remove deprecated settings by default and prioritize new ones', () => { const userSettingsContent = { general: { disableAutoUpdate: true, @@ -2177,27 +2297,11 @@ describe('Settings Loading and Merging', () => { }; const loadedSettings = createMockSettings(userSettingsContent); - const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); - // 1. removeDeprecated = false (default) + // Default is now removeDeprecated = true migrateDeprecatedSettings(loadedSettings); - // Should still have old settings - expect( - loadedSettings.forScope(SettingScope.User).settings.general, - ).toHaveProperty('disableAutoUpdate'); - expect( - ( - loadedSettings.forScope(SettingScope.User).settings.context as { - fileFiltering: { disableFuzzySearch: boolean }; - } - ).fileFiltering, - ).toHaveProperty('disableFuzzySearch'); - - // 2. removeDeprecated = true - migrateDeprecatedSettings(loadedSettings, true); - // Should remove disableAutoUpdate and trust enableAutoUpdate: true expect(setValueSpy).toHaveBeenCalledWith(SettingScope.User, 'general', { enableAutoUpdate: true, @@ -2209,9 +2313,41 @@ describe('Settings Loading and Merging', () => { }); }); + it('should preserve deprecated settings when removeDeprecated is explicitly false', () => { + const userSettingsContent = { + general: { + disableAutoUpdate: true, + enableAutoUpdate: true, + }, + context: { + fileFiltering: { + disableFuzzySearch: false, + enableFuzzySearch: false, + }, + }, + }; + + const loadedSettings = createMockSettings(userSettingsContent); + + migrateDeprecatedSettings(loadedSettings, false); + + // Should still have old settings since removeDeprecated = false + expect( + loadedSettings.forScope(SettingScope.User).settings.general, + ).toHaveProperty('disableAutoUpdate'); + expect( + ( + loadedSettings.forScope(SettingScope.User).settings.context as { + fileFiltering: { disableFuzzySearch: boolean }; + } + ).fileFiltering, + ).toHaveProperty('disableFuzzySearch'); + }); + it('should trigger migration automatically during loadSettings', () => { mockFsExistsSync.mockImplementation( - (p: fs.PathLike) => p === USER_SETTINGS_PATH, + (p: fs.PathLike) => + normalizePath(p) === normalizePath(USER_SETTINGS_PATH), ); const userSettingsContent = { general: { @@ -2220,7 +2356,7 @@ describe('Settings Loading and Merging', () => { }; (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -2255,10 +2391,10 @@ describe('Settings Loading and Merging', () => { vi.mocked(fs.existsSync).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } - if (p === getSystemDefaultsPath()) { + if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) { return JSON.stringify(systemDefaultsContent); } return '{}'; @@ -2328,7 +2464,7 @@ describe('Settings Loading and Merging', () => { vi.mocked(fs.existsSync).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; @@ -2379,7 +2515,7 @@ describe('Settings Loading and Merging', () => { vi.mocked(fs.existsSync).mockReturnValue(true); (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === USER_SETTINGS_PATH) + if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) return JSON.stringify(userSettingsContent); return '{}'; }, @@ -2415,13 +2551,16 @@ describe('Settings Loading and Merging', () => { it('should save settings using updateSettingsFilePreservingFormat', () => { const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat); const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user; - settingsFile.path = '/mock/settings.json'; + settingsFile.path = path.resolve('/mock/settings.json'); saveSettings(settingsFile); - expect(mockUpdateSettings).toHaveBeenCalledWith('/mock/settings.json', { - ui: { theme: 'dark' }, - }); + expect(mockUpdateSettings).toHaveBeenCalledWith( + path.resolve('/mock/settings.json'), + { + ui: { theme: 'dark' }, + }, + ); }); it('should create directory if it does not exist', () => { @@ -2430,14 +2569,19 @@ describe('Settings Loading and Merging', () => { mockFsExistsSync.mockReturnValue(false); const settingsFile = createMockSettings({}).user; - settingsFile.path = '/mock/new/dir/settings.json'; + settingsFile.path = path.resolve('/mock/new/dir/settings.json'); saveSettings(settingsFile); - expect(mockFsExistsSync).toHaveBeenCalledWith('/mock/new/dir'); - expect(mockFsMkdirSync).toHaveBeenCalledWith('/mock/new/dir', { - recursive: true, - }); + expect(mockFsExistsSync).toHaveBeenCalledWith( + path.resolve('/mock/new/dir'), + ); + expect(mockFsMkdirSync).toHaveBeenCalledWith( + path.resolve('/mock/new/dir'), + { + recursive: true, + }, + ); }); it('should emit error feedback if saving fails', () => { @@ -2448,13 +2592,13 @@ describe('Settings Loading and Merging', () => { }); const settingsFile = createMockSettings({}).user; - settingsFile.path = '/mock/settings.json'; + settingsFile.path = path.resolve('/mock/settings.json'); saveSettings(settingsFile); expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'error', - 'There was an error saving your latest settings changes.', + 'Failed to save settings: Write failed', error, ); }); @@ -2476,7 +2620,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; @@ -2523,7 +2667,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; @@ -2564,7 +2708,7 @@ describe('Settings Loading and Merging', () => { (fs.readFileSync as Mock).mockImplementation( (p: fs.PathOrFileDescriptor) => { - if (p === getSystemSettingsPath()) { + if (normalizePath(p) === normalizePath(getSystemSettingsPath())) { return JSON.stringify(systemSettingsContent); } return '{}'; @@ -2607,6 +2751,28 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers); }); + it('should map requiredMcpConfig from remote settings', () => { + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + const requiredMcpConfig = { + 'corp-tool': { + url: 'https://mcp.corp/tool', + type: 'http' as const, + trust: true, + }, + }; + + loadedSettings.setRemoteAdminSettings({ + mcpSetting: { + mcpEnabled: true, + requiredMcpConfig, + }, + }); + + expect(loadedSettings.merged.admin?.mcp?.requiredConfig).toEqual( + requiredMcpConfig, + ); + }); + it('should set skills based on unmanagedCapabilitiesEnabled', () => { const loadedSettings = loadSettings(); loadedSettings.setRemoteAdminSettings({ @@ -2679,7 +2845,7 @@ describe('Settings Loading and Merging', () => { beforeEach(() => { const emptySettingsFile: SettingsFile = { - path: '/mock/path', + path: path.resolve('/mock/path'), settings: {}, originalSettings: {}, }; @@ -3004,7 +3170,7 @@ describe('LoadedSettings Isolation and Serializability', () => { // Create a minimal LoadedSettings instance const emptyScope = { - path: '/mock/settings.json', + path: path.resolve('/mock/settings.json'), settings: {}, originalSettings: {}, } as unknown as SettingsFile; diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 657968a3b6..984bdb8d60 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -14,14 +14,16 @@ import { FatalConfigError, GEMINI_DIR, getErrorMessage, + getFsErrorMessage, Storage, coreEvents, homedir, type AdminControlsSettings, + createCache, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; -import { DefaultLight } from '../ui/themes/default-light.js'; -import { DefaultDark } from '../ui/themes/default.js'; +import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; +import { DefaultDark } from '../ui/themes/builtin/dark/default-dark.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { type Settings, @@ -185,9 +187,6 @@ export interface SessionRetentionSettings { /** Minimum retention period (safety limit, defaults to "1d") */ minRetention?: string; - - /** INTERNAL: Whether the user has acknowledged the session retention warning */ - warningAcknowledged?: boolean; } export interface SettingsError { @@ -481,6 +480,7 @@ export class LoadedSettings { admin.mcp = { enabled: mcpSetting?.mcpEnabled, config: mcpSetting?.mcpConfig?.mcpServers, + requiredConfig: mcpSetting?.requiredMcpConfig, }; admin.extensions = { enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled, @@ -618,6 +618,24 @@ export function loadEnvironment( } } +// Cache to store the results of loadSettings to avoid redundant disk I/O. +const settingsCache = createCache({ + storage: 'map', + defaultTtl: 10000, // 10 seconds +}); + +/** + * Resets the settings cache. Used exclusively for test isolation. + * @internal + */ +export function resetSettingsCacheForTesting() { + settingsCache.clear(); +} + +export function isWorktreeEnabled(settings: LoadedSettings): boolean { + return settings.merged.experimental.worktrees; +} + /** * Loads settings from user and workspace directories. * Project settings override user settings. @@ -625,6 +643,16 @@ export function loadEnvironment( export function loadSettings( workspaceDir: string = process.cwd(), ): LoadedSettings { + const normalizedWorkspaceDir = path.resolve(workspaceDir); + return settingsCache.getOrCreate(normalizedWorkspaceDir, () => + _doLoadSettings(normalizedWorkspaceDir), + ); +} + +/** + * Internal implementation of the settings loading logic. + */ +function _doLoadSettings(workspaceDir: string): LoadedSettings { let systemSettings: Settings = {}; let systemDefaultSettings: Settings = {}; let userSettings: Settings = {}; @@ -799,14 +827,13 @@ export function loadSettings( /** * Migrates deprecated settings to their new counterparts. * - * TODO: After a couple of weeks (around early Feb 2026), we should start removing - * the deprecated settings from the settings files by default. + * Deprecated settings are removed from settings files by default. * * @returns true if any changes were made and need to be saved. */ export function migrateDeprecatedSettings( loadedSettings: LoadedSettings, - removeDeprecated = false, + removeDeprecated = true, ): boolean { let anyModified = false; const systemWarnings: Map = new Map(); @@ -1033,6 +1060,9 @@ export function migrateDeprecatedSettings( } export function saveSettings(settingsFile: SettingsFile): void { + // Clear the entire cache on any save. + settingsCache.clear(); + try { // Ensure the directory exists const dirPath = path.dirname(settingsFile.path); @@ -1048,9 +1078,10 @@ export function saveSettings(settingsFile: SettingsFile): void { settingsToSave as Record, ); } catch (error) { + const detailedErrorMessage = getFsErrorMessage(error); coreEvents.emitFeedback( 'error', - 'There was an error saving your latest settings changes.', + `Failed to save settings: ${detailedErrorMessage}`, error, ); } @@ -1063,9 +1094,10 @@ export function saveModelChange( try { loadedSettings.setValue(SettingScope.User, 'model.name', model); } catch (error) { + const detailedErrorMessage = getFsErrorMessage(error); coreEvents.emitFeedback( 'error', - 'There was an error saving your preferred model.', + `Failed to save preferred model: ${detailedErrorMessage}`, error, ); } diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index ffe1dd2ac5..c358cd65aa 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -96,6 +96,14 @@ describe('SettingsSchema', () => { ]); }); + it('should have errorVerbosity enum property', () => { + const definition = getSettingsSchema().ui?.properties?.errorVerbosity; + expect(definition).toBeDefined(); + expect(definition?.type).toBe('enum'); + expect(definition?.default).toBe('low'); + expect(definition?.options?.map((o) => o.value)).toEqual(['low', 'full']); + }); + it('should have checkpointing nested properties', () => { expect( getSettingsSchema().general?.properties?.checkpointing.properties @@ -392,12 +400,10 @@ describe('SettingsSchema', () => { expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('Experimental'); - expect(setting.default).toBe(false); + expect(setting.default).toBe(true); expect(setting.requiresRestart).toBe(true); expect(setting.showInDialog).toBe(false); - expect(setting.description).toBe( - 'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents', - ); + expect(setting.description).toBe('Enable local and remote subagents.'); }); it('should have skills setting enabled by default', () => { @@ -416,12 +422,10 @@ describe('SettingsSchema', () => { expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('Experimental'); - expect(setting.default).toBe(false); + expect(setting.default).toBe(true); expect(setting.requiresRestart).toBe(true); expect(setting.showInDialog).toBe(true); - expect(setting.description).toBe( - 'Enable planning features (Plan Mode and tools).', - ); + expect(setting.description).toBe('Enable Plan Mode.'); }); it('should have hooksConfig.notifications setting in schema', () => { @@ -444,6 +448,60 @@ describe('SettingsSchema', () => { expect(hookItemProperties.description).toBeDefined(); expect(hookItemProperties.description.type).toBe('string'); }); + + it('should have gemmaModelRouter setting in schema', () => { + const gemmaModelRouter = + getSettingsSchema().experimental.properties.gemmaModelRouter; + expect(gemmaModelRouter).toBeDefined(); + expect(gemmaModelRouter.type).toBe('object'); + expect(gemmaModelRouter.category).toBe('Experimental'); + expect(gemmaModelRouter.default).toEqual({}); + expect(gemmaModelRouter.requiresRestart).toBe(true); + expect(gemmaModelRouter.showInDialog).toBe(false); + expect(gemmaModelRouter.description).toBe( + 'Enable Gemma model router (experimental).', + ); + + const enabled = gemmaModelRouter.properties.enabled; + expect(enabled).toBeDefined(); + expect(enabled.type).toBe('boolean'); + expect(enabled.category).toBe('Experimental'); + expect(enabled.default).toBe(false); + expect(enabled.requiresRestart).toBe(true); + expect(enabled.showInDialog).toBe(false); + expect(enabled.description).toBe( + 'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + ); + + const classifier = gemmaModelRouter.properties.classifier; + expect(classifier).toBeDefined(); + expect(classifier.type).toBe('object'); + expect(classifier.category).toBe('Experimental'); + expect(classifier.default).toEqual({}); + expect(classifier.requiresRestart).toBe(true); + expect(classifier.showInDialog).toBe(false); + expect(classifier.description).toBe('Classifier configuration.'); + + const host = classifier.properties.host; + expect(host).toBeDefined(); + expect(host.type).toBe('string'); + expect(host.category).toBe('Experimental'); + expect(host.default).toBe('http://localhost:9379'); + expect(host.requiresRestart).toBe(true); + expect(host.showInDialog).toBe(false); + expect(host.description).toBe('The host of the classifier.'); + + const model = classifier.properties.model; + expect(model).toBeDefined(); + expect(model.type).toBe('string'); + expect(model.category).toBe('Experimental'); + expect(model.default).toBe('gemma3-1b-gpu-custom'); + expect(model.requiresRestart).toBe(true); + expect(model.showInDialog).toBe(false); + expect(model.description).toBe( + 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', + ); + }); }); it('has JSON schema definitions for every referenced ref', () => { @@ -480,8 +538,32 @@ describe('SettingsSchema', () => { } }; + const visitJsonSchema = (jsonSchema: Record) => { + const ref = jsonSchema['ref']; + if (typeof ref === 'string') { + referenced.add(ref); + } + const properties = jsonSchema['properties']; + if ( + properties && + typeof properties === 'object' && + !Array.isArray(properties) + ) { + Object.values(properties as Record).forEach((prop) => + visitJsonSchema(prop as Record), + ); + } + const items = jsonSchema['items']; + if (items && typeof items === 'object' && !Array.isArray(items)) { + visitJsonSchema(items as Record); + } + }; + Object.values(schema).forEach(visitDefinition); + // Also visit all definitions to find nested references + Object.values(SETTINGS_SCHEMA_DEFINITIONS).forEach(visitJsonSchema); + // Ensure definitions map doesn't accumulate stale entries. Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => { if (!referenced.has(key)) { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 26faaafda7..277dcfdcb9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -12,12 +12,15 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, + AuthProviderType, type MCPServerConfig, + type RequiredMcpServerConfig, type BugCommandSettings, type TelemetrySettings, type AuthType, type AgentOverride, type CustomTheme, + type SandboxConfig, } from '@google/gemini-cli-core'; import type { SessionRetentionSettings } from './settings.js'; import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js'; @@ -117,6 +120,10 @@ export interface SettingDefinition { * For map-like objects without explicit `properties`, describes the shape of the values. */ additionalProperties?: SettingCollectionDefinition; + /** + * Optional unit to display after the value (e.g. '%'). + */ + unit?: string; /** * Optional reference identifier for generators that emit a `$ref`. */ @@ -130,6 +137,18 @@ export interface SettingsSchema { export type MemoryImportFormat = 'tree' | 'flat'; export type DnsResolutionOrder = 'ipv4first' | 'verbatim'; +const pathArraySetting = (label: string, description: string) => ({ + type: 'array' as const, + label, + category: 'Advanced' as const, + requiresRestart: true as const, + default: [] as string[], + description, + showInDialog: false as const, + items: { type: 'string' as const }, + mergeStrategy: MergeStrategy.UNION, +}); + /** * The canonical schema for all settings. * The structure of this object defines the structure of the `Settings` type. @@ -152,17 +171,15 @@ const SETTINGS_SCHEMA = { }, }, - policyPaths: { - type: 'array', - label: 'Policy Paths', - category: 'Advanced', - requiresRestart: true, - default: [] as string[], - description: 'Additional policy files or directories to load.', - showInDialog: false, - items: { type: 'string' }, - mergeStrategy: MergeStrategy.UNION, - }, + policyPaths: pathArraySetting( + 'Policy Paths', + 'Additional policy files or directories to load.', + ), + + adminPolicyPaths: pathArraySetting( + 'Admin Policy Paths', + 'Additional admin policy files or directories to load.', + ), general: { type: 'object', @@ -200,7 +217,8 @@ const SETTINGS_SCHEMA = { description: oneLine` The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, - and 'plan' is read-only mode. 'yolo' is not supported yet. + and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can + only be enabled via command line (--yolo or --approval-mode=yolo). `, showInDialog: true, options: [ @@ -302,10 +320,10 @@ const SETTINGS_SCHEMA = { label: 'Retry Fetch Errors', category: 'General', requiresRestart: false, - default: false, + default: true, description: 'Retry on "exception TypeError: fetch failed sending request" errors.', - showInDialog: false, + showInDialog: true, }, maxAttempts: { type: 'number', @@ -339,7 +357,7 @@ const SETTINGS_SCHEMA = { label: 'Enable Session Cleanup', category: 'General', requiresRestart: false, - default: false, + default: true as boolean, description: 'Enable automatic session cleanup', showInDialog: true, }, @@ -348,7 +366,7 @@ const SETTINGS_SCHEMA = { label: 'Keep chat history', category: 'General', requiresRestart: false, - default: undefined as string | undefined, + default: '30d' as string, description: 'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")', showInDialog: true, @@ -372,16 +390,6 @@ const SETTINGS_SCHEMA = { description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`, showInDialog: false, }, - warningAcknowledged: { - type: 'boolean', - label: 'Warning Acknowledged', - category: 'General', - requiresRestart: false, - default: false, - showInDialog: false, - description: - 'INTERNAL: Whether the user has acknowledged the session retention warning', - }, }, description: 'Settings for automatic session cleanup.', }, @@ -534,6 +542,16 @@ const SETTINGS_SCHEMA = { description: 'Hide helpful tips in the UI', showInDialog: true, }, + escapePastedAtSymbols: { + type: 'boolean', + label: 'Escape Pasted @ Symbols', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.', + showInDialog: true, + }, showShortcutsHint: { type: 'boolean', label: 'Show Shortcuts Hint', @@ -571,14 +589,34 @@ const SETTINGS_SCHEMA = { description: 'Settings for the footer.', showInDialog: false, properties: { + items: { + type: 'array', + label: 'Footer Items', + category: 'UI', + requiresRestart: false, + default: undefined as string[] | undefined, + description: + 'List of item IDs to display in the footer. Rendered in order', + showInDialog: false, + items: { type: 'string' }, + }, + showLabels: { + type: 'boolean', + label: 'Show Footer Labels', + category: 'UI', + requiresRestart: false, + default: true, + description: + 'Display a second line above the footer items with descriptive headers (e.g., /model).', + showInDialog: false, + }, hideCWD: { type: 'boolean', label: 'Hide CWD', category: 'UI', requiresRestart: false, default: false, - description: - 'Hide the current working directory path in the footer.', + description: 'Hide the current working directory in the footer.', showInDialog: true, }, hideSandboxStatus: { @@ -605,7 +643,7 @@ const SETTINGS_SCHEMA = { category: 'UI', requiresRestart: false, default: true, - description: 'Hides the context window remaining percentage.', + description: 'Hides the context window usage percentage.', showInDialog: true, }, }, @@ -662,7 +700,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: true, description: - "Show the logged-in user's identity (e.g. email) in the UI.", + "Show the signed-in user's identity (e.g. email) in the UI.", showInDialog: true, }, useAlternateBuffer: { @@ -719,6 +757,20 @@ const SETTINGS_SCHEMA = { { value: 'off', label: 'Off' }, ], }, + errorVerbosity: { + type: 'enum', + label: 'Error Verbosity', + category: 'UI', + requiresRestart: false, + default: 'low', + description: + 'Controls whether recoverable errors are hidden (low) or fully shown (full).', + showInDialog: true, + options: [ + { value: 'low', label: 'Low' }, + { value: 'full', label: 'Full' }, + ], + }, customWittyPhrases: { type: 'array', label: 'Custom Witty Phrases', @@ -828,6 +880,36 @@ const SETTINGS_SCHEMA = { ref: 'TelemetrySettings', }, + billing: { + type: 'object', + label: 'Billing', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Billing and AI credits settings.', + showInDialog: false, + properties: { + overageStrategy: { + type: 'enum', + label: 'Overage Strategy', + category: 'Advanced', + requiresRestart: false, + default: 'ask', + description: oneLine` + How to handle quota exhaustion when AI credits are available. + 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + `, + showInDialog: true, + options: [ + { value: 'ask', label: 'Ask each time' }, + { value: 'always', label: 'Always use credits' }, + { value: 'never', label: 'Never use credits' }, + ], + }, + }, + }, + model: { type: 'object', label: 'Model', @@ -879,13 +961,14 @@ const SETTINGS_SCHEMA = { }, compressionThreshold: { type: 'number', - label: 'Compression Threshold', + label: 'Context Compression Threshold', category: 'Model', requiresRestart: true, default: 0.5 as number, description: 'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).', showInDialog: true, + unit: '%', }, disableLoopDetection: { type: 'boolean', @@ -958,6 +1041,62 @@ const SETTINGS_SCHEMA = { 'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.', showInDialog: false, }, + modelDefinitions: { + type: 'object', + label: 'Model Definitions', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelDefinitions, + description: + 'Registry of model metadata, including tier, family, and features.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'ModelDefinition', + }, + }, + modelIdResolutions: { + type: 'object', + label: 'Model ID Resolutions', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelIdResolutions, + description: + 'Rules for resolving requested model names to concrete model IDs based on context.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'ModelResolution', + }, + }, + classifierIdResolutions: { + type: 'object', + label: 'Classifier ID Resolutions', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.classifierIdResolutions, + description: + 'Rules for resolving classifier tiers (flash, pro) to concrete model IDs.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'ModelResolution', + }, + }, + modelChains: { + type: 'object', + label: 'Model Chains', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelChains, + description: + 'Availability policy chains defining fallback behavior for models.', + showInDialog: false, + additionalProperties: { + type: 'array', + ref: 'ModelPolicyChain', + }, + }, }, }, @@ -1036,6 +1175,49 @@ const SETTINGS_SCHEMA = { description: 'Model override for the visual agent.', showInDialog: false, }, + allowedDomains: { + type: 'array', + label: 'Allowed Domains', + category: 'Advanced', + requiresRestart: true, + default: ['github.com', '*.google.com', 'localhost'] as string[], + description: oneLine` + A list of allowed domains for the browser agent + (e.g., ["github.com", "*.google.com"]). + `, + showInDialog: false, + items: { type: 'string' }, + }, + disableUserInput: { + type: 'boolean', + label: 'Disable User Input', + category: 'Advanced', + requiresRestart: false, + default: true, + description: + 'Disable user input on browser window during automation.', + showInDialog: false, + }, + confirmSensitiveActions: { + type: 'boolean', + label: 'Confirm Sensitive Actions', + category: 'Advanced', + requiresRestart: true, + default: false, + description: + 'Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script).', + showInDialog: true, + }, + blockFileUploads: { + type: 'boolean', + label: 'Block File Uploads', + category: 'Advanced', + requiresRestart: true, + default: false, + description: + 'Hard-block file upload requests from the browser agent.', + showInDialog: true, + }, }, }, }, @@ -1110,7 +1292,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: oneLine` - Controls how /memory refresh loads GEMINI.md files. + Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. `, showInDialog: true, @@ -1193,14 +1375,35 @@ const SETTINGS_SCHEMA = { label: 'Sandbox', category: 'Tools', requiresRestart: true, - default: undefined as boolean | string | undefined, - ref: 'BooleanOrString', + default: undefined as boolean | string | SandboxConfig | undefined, + ref: 'BooleanOrStringOrObject', description: oneLine` - Sandbox execution environment. - Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile. + Legacy full-process sandbox execution environment. + Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, + or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). `, showInDialog: false, }, + sandboxAllowedPaths: { + type: 'array', + label: 'Sandbox Allowed Paths', + category: 'Tools', + requiresRestart: true, + default: [] as string[], + description: + 'List of additional paths that the sandbox is allowed to access.', + showInDialog: true, + items: { type: 'string' }, + }, + sandboxNetworkAccess: { + type: 'boolean', + label: 'Sandbox Network Access', + category: 'Tools', + requiresRestart: true, + default: false, + description: 'Whether the sandbox is allowed to access the network.', + showInDialog: true, + }, shell: { type: 'object', label: 'Shell', @@ -1417,6 +1620,16 @@ const SETTINGS_SCHEMA = { description: 'Security-related settings.', showInDialog: false, properties: { + toolSandboxing: { + type: 'boolean', + label: 'Tool Sandboxing', + category: 'Security', + requiresRestart: false, + default: false, + description: + 'Experimental tool-level sandboxing (implementation in progress).', + showInDialog: true, + }, disableYoloMode: { type: 'boolean', label: 'Disable YOLO Mode', @@ -1426,6 +1639,16 @@ const SETTINGS_SCHEMA = { description: 'Disable YOLO mode, even if enabled by a flag.', showInDialog: true, }, + disableAlwaysAllow: { + type: 'boolean', + label: 'Disable Always Allow', + category: 'Security', + requiresRestart: true, + default: false, + description: + 'Disable "Always allow" options in tool confirmation dialogs.', + showInDialog: true, + }, enablePermanentToolApproval: { type: 'boolean', label: 'Allow Permanent Tool Approval', @@ -1436,6 +1659,18 @@ const SETTINGS_SCHEMA = { 'Enable the "Allow for all future sessions" option in tool confirmation dialogs.', showInDialog: true, }, + autoAddToPolicyByDefault: { + type: 'boolean', + label: 'Auto-add to Policy by Default', + category: 'Security', + requiresRestart: false, + default: false, + description: oneLine` + When enabled, the "Allow for all future sessions" option becomes the + default choice for low-risk tools in trusted workspaces. + `, + showInDialog: true, + }, blockGitExtensions: { type: 'boolean', label: 'Blocks extensions from Git', @@ -1687,10 +1922,19 @@ const SETTINGS_SCHEMA = { label: 'Enable Agents', category: 'Experimental', requiresRestart: true, + default: true, + description: 'Enable local and remote subagents.', + showInDialog: false, + }, + worktrees: { + type: 'boolean', + label: 'Enable Git Worktrees', + category: 'Experimental', + requiresRestart: true, default: false, description: - 'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents', - showInDialog: false, + 'Enable automated Git worktree management for parallel work.', + showInDialog: true, }, extensionManagement: { type: 'boolean', @@ -1719,6 +1963,16 @@ const SETTINGS_SCHEMA = { description: 'Enable extension registry explore UI.', showInDialog: false, }, + extensionRegistryURI: { + type: 'string', + label: 'Extension Registry URI', + category: 'Experimental', + requiresRestart: true, + default: 'https://geminicli.com/extensions.json', + description: + 'The URI (web URL or local file path) of the extension registry.', + showInDialog: false, + }, extensionReloading: { type: 'boolean', label: 'Extension Reloading', @@ -1734,7 +1988,7 @@ const SETTINGS_SCHEMA = { label: 'JIT Context Loading', category: 'Experimental', requiresRestart: true, - default: false, + default: true, description: 'Enable Just-In-Time (JIT) context loading.', showInDialog: false, }, @@ -1763,10 +2017,19 @@ const SETTINGS_SCHEMA = { label: 'Plan', category: 'Experimental', requiresRestart: true, - default: false, - description: 'Enable planning features (Plan Mode and tools).', + default: true, + description: 'Enable Plan Mode.', showInDialog: true, }, + taskTracker: { + type: 'boolean', + label: 'Task Tracker', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable task tracker tools.', + showInDialog: false, + }, modelSteering: { type: 'boolean', label: 'Model Steering', @@ -1787,9 +2050,89 @@ const SETTINGS_SCHEMA = { 'Enable web fetch behavior that bypasses LLM summarization.', showInDialog: true, }, + dynamicModelConfiguration: { + type: 'boolean', + label: 'Dynamic Model Configuration', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.', + showInDialog: false, + }, + gemmaModelRouter: { + type: 'object', + label: 'Gemma Model Router', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Enable Gemma model router (experimental).', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Gemma Model Router', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + showInDialog: false, + }, + classifier: { + type: 'object', + label: 'Classifier', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Classifier configuration.', + showInDialog: false, + properties: { + host: { + type: 'string', + label: 'Host', + category: 'Experimental', + requiresRestart: true, + default: 'http://localhost:9379', + description: 'The host of the classifier.', + showInDialog: false, + }, + model: { + type: 'string', + label: 'Model', + category: 'Experimental', + requiresRestart: true, + default: 'gemma3-1b-gpu-custom', + description: + 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', + showInDialog: false, + }, + }, + }, + }, + }, + memoryManager: { + type: 'boolean', + label: 'Memory Manager Agent', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.', + showInDialog: true, + }, + topicUpdateNarration: { + type: 'boolean', + label: 'Topic & Update Narration', + category: 'Experimental', + requiresRestart: false, + default: false, + description: + 'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.', + showInDialog: true, + }, }, }, - extensions: { type: 'object', label: 'Extensions', @@ -2070,7 +2413,8 @@ const SETTINGS_SCHEMA = { category: 'Admin', requiresRestart: false, default: false, - description: 'If true, disallows yolo mode from being used.', + description: + 'If true, disallows YOLO mode and "Always allow" options from being used.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, }, @@ -2123,7 +2467,7 @@ const SETTINGS_SCHEMA = { category: 'Admin', requiresRestart: false, default: {} as Record, - description: 'Admin-configured MCP servers.', + description: 'Admin-configured MCP servers (allowlist).', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, additionalProperties: { @@ -2131,6 +2475,20 @@ const SETTINGS_SCHEMA = { ref: 'MCPServerConfig', }, }, + requiredConfig: { + type: 'object', + label: 'Required MCP Config', + category: 'Admin', + requiresRestart: false, + default: {} as Record, + description: 'Admin-required MCP servers that are always injected.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + additionalProperties: { + type: 'object', + ref: 'RequiredMcpServerConfig', + }, + }, }, }, skills: { @@ -2255,11 +2613,72 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< type: 'string', description: 'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).', - enum: [ - 'dynamic_discovery', - 'google_credentials', - 'service_account_impersonation', - ], + enum: Object.values(AuthProviderType), + }, + targetAudience: { + type: 'string', + description: + 'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).', + }, + targetServiceAccount: { + type: 'string', + description: + 'Service account email to impersonate (name@project.iam.gserviceaccount.com).', + }, + }, + }, + RequiredMcpServerConfig: { + type: 'object', + description: + 'Admin-required MCP server configuration (remote transports only).', + additionalProperties: false, + properties: { + url: { + type: 'string', + description: 'URL for the required MCP server.', + }, + type: { + type: 'string', + description: 'Transport type for the required server.', + enum: ['sse', 'http'], + }, + headers: { + type: 'object', + description: 'Additional HTTP headers sent to the server.', + additionalProperties: { type: 'string' }, + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds for MCP requests.', + }, + trust: { + type: 'boolean', + description: + 'Marks the server as trusted. Defaults to true for admin-required servers.', + }, + description: { + type: 'string', + description: 'Human-readable description of the server.', + }, + includeTools: { + type: 'array', + description: 'Subset of tools enabled for this server.', + items: { type: 'string' }, + }, + excludeTools: { + type: 'array', + description: 'Tools disabled for this server.', + items: { type: 'string' }, + }, + oauth: { + type: 'object', + description: 'OAuth configuration for authenticating with the server.', + additionalProperties: true, + }, + authProviderType: { + type: 'string', + description: 'Authentication provider used for acquiring credentials.', + enum: Object.values(AuthProviderType), }, targetAudience: { type: 'string', @@ -2465,9 +2884,44 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< description: 'Accepts either a single string or an array of strings.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], }, - BooleanOrString: { - description: 'Accepts either a boolean flag or a string command name.', - anyOf: [{ type: 'boolean' }, { type: 'string' }], + BooleanOrStringOrObject: { + description: + 'Accepts either a boolean flag, a string command name, or a configuration object.', + anyOf: [ + { type: 'boolean' }, + { type: 'string' }, + { + type: 'object', + description: 'Sandbox configuration object.', + additionalProperties: false, + properties: { + enabled: { + type: 'boolean', + description: 'Enables or disables the sandbox.', + }, + command: { + type: 'string', + description: + 'The sandbox command to use (docker, podman, sandbox-exec, runsc, lxc).', + enum: ['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc'], + }, + image: { + type: 'string', + description: 'The sandbox image to use.', + }, + allowedPaths: { + type: 'array', + description: + 'A list of absolute host paths that should be accessible within the sandbox.', + items: { type: 'string' }, + }, + networkAccess: { + type: 'boolean', + description: 'Whether the sandbox should have internet access.', + }, + }, + }, + ], }, HookDefinitionArray: { type: 'array', @@ -2517,6 +2971,89 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + ModelDefinition: { + type: 'object', + description: 'Model metadata registry entry.', + properties: { + displayName: { type: 'string' }, + tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] }, + family: { type: 'string' }, + isPreview: { type: 'boolean' }, + isVisible: { type: 'boolean' }, + dialogDescription: { type: 'string' }, + features: { + type: 'object', + properties: { + thinking: { type: 'boolean' }, + multimodalToolUse: { type: 'boolean' }, + }, + }, + }, + }, + ModelResolution: { + type: 'object', + description: 'Model resolution rule.', + properties: { + default: { type: 'string' }, + contexts: { + type: 'array', + items: { + type: 'object', + properties: { + condition: { + type: 'object', + properties: { + useGemini3_1: { type: 'boolean' }, + useCustomTools: { type: 'boolean' }, + hasAccessToPreview: { type: 'boolean' }, + requestedModels: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + target: { type: 'string' }, + }, + }, + }, + }, + }, + ModelPolicyChain: { + type: 'array', + description: 'A chain of model policies for fallback behavior.', + items: { + type: 'object', + ref: 'ModelPolicy', + }, + }, + ModelPolicy: { + type: 'object', + description: + 'Defines the policy for a single model in the availability chain.', + properties: { + model: { type: 'string' }, + isLastResort: { type: 'boolean' }, + actions: { + type: 'object', + properties: { + terminal: { type: 'string', enum: ['silent', 'prompt'] }, + transient: { type: 'string', enum: ['silent', 'prompt'] }, + not_found: { type: 'string', enum: ['silent', 'prompt'] }, + unknown: { type: 'string', enum: ['silent', 'prompt'] }, + }, + }, + stateTransitions: { + type: 'object', + properties: { + terminal: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + transient: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + not_found: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + unknown: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + }, + }, + }, + required: ['model'], + }, }; export function getSettingsSchema(): SettingsSchemaType { @@ -2532,7 +3069,11 @@ type InferSettings = { : T[K]['default'] : T[K]['default'] extends boolean ? boolean - : T[K]['default']; + : T[K]['default'] extends string + ? string + : T[K]['default'] extends ReadonlyArray + ? U[] + : T[K]['default']; }; type InferMergedSettings = { @@ -2544,7 +3085,11 @@ type InferMergedSettings = { : T[K]['default'] : T[K]['default'] extends boolean ? boolean - : T[K]['default']; + : T[K]['default'] extends string + ? string + : T[K]['default'] extends ReadonlyArray + ? U[] + : T[K]['default']; }; export type Settings = InferSettings; diff --git a/packages/cli/src/config/settings_validation_warning.test.ts b/packages/cli/src/config/settings_validation_warning.test.ts index 498f803dd9..435c797d81 100644 --- a/packages/cli/src/config/settings_validation_warning.test.ts +++ b/packages/cli/src/config/settings_validation_warning.test.ts @@ -81,6 +81,7 @@ import { loadSettings, USER_SETTINGS_PATH, type LoadedSettings, + resetSettingsCacheForTesting, } from './settings.js'; const MOCK_WORKSPACE_DIR = '/mock/workspace'; @@ -88,6 +89,7 @@ const MOCK_WORKSPACE_DIR = '/mock/workspace'; describe('Settings Validation Warning', () => { beforeEach(() => { vi.clearAllMocks(); + resetSettingsCacheForTesting(); (fs.readFileSync as Mock).mockReturnValue('{}'); (fs.existsSync as Mock).mockReturnValue(false); }); diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 714d703241..2741da875f 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -19,9 +19,8 @@ import { isWorkspaceTrusted, resetTrustedFoldersForTesting, } from './trustedFolders.js'; -import { loadEnvironment } from './settings.js'; +import { loadEnvironment, type Settings } from './settings.js'; import { createMockSettings } from '../test-utils/settings.js'; -import type { Settings } from './settings.js'; // We explicitly do NOT mock 'fs' or 'proper-lockfile' here to ensure // we are testing the actual behavior on the real file system. @@ -506,7 +505,7 @@ describe('Trusted Folders', () => { const realDir = path.join(tempDir, 'real'); const symlinkDir = path.join(tempDir, 'symlink'); fs.mkdirSync(realDir); - fs.symlinkSync(realDir, symlinkDir); + fs.symlinkSync(realDir, symlinkDir, 'dir'); // Rule uses realpath const config = { [realDir]: TrustLevel.TRUST_FOLDER }; diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index a7ab9d69b1..d0d98a5a31 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -54,6 +54,7 @@ describe('Workspace-Level Policy CLI Integration', () => { beforeEach(() => { vi.clearAllMocks(); + Policy.setDisableWorkspacePolicies(false); // Default to MATCH for existing tests mockCheckIntegrity.mockResolvedValue({ status: 'match', diff --git a/packages/cli/src/core/auth.test.ts b/packages/cli/src/core/auth.test.ts index c844ee6f93..639ed20a89 100644 --- a/packages/cli/src/core/auth.test.ts +++ b/packages/cli/src/core/auth.test.ts @@ -9,6 +9,7 @@ import { performInitialAuth } from './auth.js'; import { type Config, ValidationRequiredError, + ProjectIdRequiredError, AuthType, } from '@google/gemini-cli-core'; @@ -17,7 +18,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...actual, - getErrorMessage: (e: unknown) => (e as Error).message, }; }); @@ -32,7 +32,7 @@ describe('auth', () => { it('should return null if authType is undefined', async () => { const result = await performInitialAuth(mockConfig, undefined); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); }); @@ -41,20 +41,23 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); }); it('should return error message on failed auth', async () => { - const error = new Error('Auth failed'); + const error = new Error('Authentication failed'); vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error); const result = await performInitialAuth( mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBe('Failed to login. Message: Auth failed'); + expect(result).toEqual({ + authError: 'Failed to sign in. Message: Authentication failed', + accountSuspensionInfo: null, + }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); @@ -68,7 +71,66 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => { + vi.mocked(mockConfig.refreshAuth).mockRejectedValue({ + response: { + data: { + error: { + code: 403, + message: + 'This service has been disabled for violation of Terms of Service.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'TOS_VIOLATION', + domain: 'example.googleapis.com', + metadata: { + appeal_url: 'https://example.com/appeal', + appeal_url_link_text: 'Appeal Here', + }, + }, + ], + }, + }, + }, + }); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ + authError: null, + accountSuspensionInfo: { + message: + 'This service has been disabled for violation of Terms of Service.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', + }, + }); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return ProjectIdRequiredError message without "Failed to login" prefix', async () => { + const projectIdError = new ProjectIdRequiredError(); + vi.mocked(mockConfig.refreshAuth).mockRejectedValue(projectIdError); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ + authError: + 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', + accountSuspensionInfo: null, + }); + expect(result.authError).not.toContain('Failed to login'); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index 7b1e8c8277..0bc89f5bda 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -9,20 +9,29 @@ import { type Config, getErrorMessage, ValidationRequiredError, + isAccountSuspendedError, + ProjectIdRequiredError, } from '@google/gemini-cli-core'; +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; + +export interface InitialAuthResult { + authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; +} + /** * Handles the initial authentication flow. * @param config The application config. * @param authType The selected auth type. - * @returns An error message if authentication fails, otherwise null. + * @returns The auth result with error message and account suspension status. */ export async function performInitialAuth( config: Config, authType: AuthType | undefined, -): Promise { +): Promise { if (!authType) { - return null; + return { authError: null, accountSuspensionInfo: null }; } try { @@ -33,10 +42,32 @@ export async function performInitialAuth( if (e instanceof ValidationRequiredError) { // Don't treat validation required as a fatal auth error during startup. // This allows the React UI to load and show the ValidationDialog. - return null; + return { authError: null, accountSuspensionInfo: null }; } - return `Failed to login. Message: ${getErrorMessage(e)}`; + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + return { + authError: null, + accountSuspensionInfo: { + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }, + }; + } + if (e instanceof ProjectIdRequiredError) { + // OAuth succeeded but account setup requires project ID + // Show the error message directly without "Failed to login" prefix + return { + authError: getErrorMessage(e), + accountSuspensionInfo: null, + }; + } + return { + authError: `Failed to sign in. Message: ${getErrorMessage(e)}`, + accountSuspensionInfo: null, + }; } - return null; + return { authError: null, accountSuspensionInfo: null }; } diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 57f1c41551..e4fdb2cba5 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -72,7 +72,10 @@ describe('initializer', () => { vi.mocked(IdeClient.getInstance).mockResolvedValue( mockIdeClient as unknown as IdeClient, ); - vi.mocked(performInitialAuth).mockResolvedValue(null); + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: null, + accountSuspensionInfo: null, + }); vi.mocked(validateTheme).mockReturnValue(null); }); @@ -84,6 +87,7 @@ describe('initializer', () => { expect(result).toEqual({ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, @@ -103,6 +107,7 @@ describe('initializer', () => { expect(result).toEqual({ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, @@ -116,7 +121,10 @@ describe('initializer', () => { }); it('should handle auth error', async () => { - vi.mocked(performInitialAuth).mockResolvedValue('Auth failed'); + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: 'Auth failed', + accountSuspensionInfo: null, + }); const result = await initializeApp( mockConfig as unknown as Config, mockSettings, diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index e99efd90f6..f27e9a9511 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -17,9 +17,11 @@ import { import { type LoadedSettings } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; export interface InitializationResult { authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; themeError: string | null; shouldOpenAuthDialog: boolean; geminiMdFileCount: number; @@ -37,7 +39,7 @@ export async function initializeApp( settings: LoadedSettings, ): Promise { const authHandle = startupProfiler.start('authenticate'); - const authError = await performInitialAuth( + const { authError, accountSuspensionInfo } = await performInitialAuth( config, settings.merged.security.auth.selectedType, ); @@ -60,6 +62,7 @@ export async function initializeApp( return { authError, + accountSuspensionInfo, themeError, shouldOpenAuthDialog, geminiMdFileCount: config.getGeminiMdFileCount(), diff --git a/packages/cli/src/deferred.test.ts b/packages/cli/src/deferred.test.ts index 99b86c9827..0a50bef309 100644 --- a/packages/cli/src/deferred.test.ts +++ b/packages/cli/src/deferred.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + type MockInstance, +} from 'vitest'; import { runDeferredCommand, defer, @@ -14,7 +21,6 @@ import { import { ExitCodes } from '@google/gemini-cli-core'; import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import { createMockSettings } from './test-utils/settings.js'; -import type { MockInstance } from 'vitest'; const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({ mockRunExitCleanup: vi.fn(), diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index c9f5a6cecd..d3d014231f 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -21,15 +21,19 @@ import { startInteractiveUI, getNodeMemoryArgs, } from './gemini.js'; -import { loadCliConfig, parseArguments } from './config/config.js'; +import { + loadCliConfig, + parseArguments, + type CliArgs, +} from './config/config.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js'; import { start_sandbox } from './utils/sandbox.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import os from 'node:os'; import v8 from 'node:v8'; -import { type CliArgs } from './config/config.js'; -import { type LoadedSettings, loadSettings } from './config/settings.js'; +import { loadSettings, type LoadedSettings } from './config/settings.js'; import { createMockConfig, createMockSettings, @@ -77,6 +81,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...actual, + createCache: + actual.createCache ?? + ((() => { + const cache = new Map(); + return { + clear: () => cache.clear(), + getOrCreate: (key: K, factory: () => V) => { + if (!cache.has(key)) { + cache.set(key, factory()); + } + return cache.get(key)!; + }, + }; + }) as typeof actual.createCache), recordSlowRender: vi.fn(), logUserPrompt: vi.fn(), writeToStdout: vi.fn((...args) => @@ -122,6 +140,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { clearInstance: vi.fn(), }, coreEvents: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...actual.coreEvents, emitFeedback: vi.fn(), emitConsoleLog: vi.fn(), @@ -189,12 +208,21 @@ vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ vi.mock('./config/config.js', () => ({ loadCliConfig: vi.fn().mockImplementation(async () => createMockConfig()), - parseArguments: vi.fn().mockResolvedValue({}), + parseArguments: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + }), isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ readPackageUp: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, packageJson: { name: 'test-pkg', version: 'test-version' }, path: '/fake/path/package.json', }), @@ -232,6 +260,9 @@ vi.mock('./utils/relaunch.js', () => ({ vi.mock('./config/sandboxConfig.js', () => ({ loadSandboxConfig: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, command: 'docker', image: 'test-image', }), @@ -481,6 +512,7 @@ describe('gemini.tsx main function kitty protocol', () => { yolo: undefined, approvalMode: undefined, policy: undefined, + adminPolicy: undefined, allowedMcpServerNames: undefined, allowedTools: undefined, experimentalAcp: undefined, @@ -536,6 +568,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -599,6 +634,9 @@ describe('gemini.tsx main function kitty protocol', () => { }); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -618,14 +656,17 @@ describe('gemini.tsx main function kitty protocol', () => { const mockConfig = createMockConfig({ isInteractive: () => false, getQuestion: () => '', - getSandbox: () => ({ command: 'docker', image: 'test-image' }), + getSandbox: () => + createMockSandboxConfig({ command: 'docker', image: 'test-image' }), }); vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); - vi.mocked(loadSandboxConfig).mockResolvedValue({ - command: 'docker', - image: 'test-image', - }); + vi.mocked(loadSandboxConfig).mockResolvedValue( + createMockSandboxConfig({ + command: 'docker', + image: 'test-image', + }), + ); process.env['GEMINI_API_KEY'] = 'test-key'; try { @@ -666,6 +707,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( @@ -694,8 +738,8 @@ describe('gemini.tsx main function kitty protocol', () => { }); it('should handle session selector error', async () => { - const { SessionSelector } = await import('./utils/sessionUtils.js'); - vi.mocked(SessionSelector).mockImplementation( + const sessionUtils = await import('./utils/sessionUtils.js'); + vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation( () => ({ resolveSession: vi @@ -721,6 +765,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, resume: 'session-id', } as any); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -747,6 +794,61 @@ describe('gemini.tsx main function kitty protocol', () => { emitFeedbackSpy.mockRestore(); }); + it('should start normally with a warning when no sessions found for resume', async () => { + const sessionUtils = await import('./utils/sessionUtils.js'); + vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation( + () => + ({ + resolveSession: vi + .fn() + .mockRejectedValue(sessionUtils.SessionError.noSessionsFound()), + }) as unknown as InstanceType, + ); + + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + promptInteractive: false, + resume: 'latest', + } as unknown as CliArgs); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + + await main(); + + // Should NOT have crashed + expect(processExitSpy).not.toHaveBeenCalled(); + // Should NOT have emitted a feedback error + expect(emitFeedbackSpy).not.toHaveBeenCalledWith( + 'error', + expect.stringContaining('Error resuming session'), + ); + processExitSpy.mockRestore(); + emitFeedbackSpy.mockRestore(); + }); + it.skip('should log error when cleanupExpiredSessions fails', async () => { const { cleanupExpiredSessions } = await import( './utils/sessionCleanup.js' @@ -773,6 +875,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( @@ -823,6 +928,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( @@ -897,6 +1005,9 @@ describe('gemini.tsx main function exit codes', () => { }), ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: true, } as unknown as CliArgs); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -913,10 +1024,12 @@ describe('gemini.tsx main function exit codes', () => { it('should exit with 41 for auth failure during sandbox setup', async () => { vi.stubEnv('SANDBOX', ''); - vi.mocked(loadSandboxConfig).mockResolvedValue({ - command: 'docker', - image: 'test-image', - }); + vi.mocked(loadSandboxConfig).mockResolvedValue( + createMockSandboxConfig({ + command: 'docker', + image: 'test-image', + }), + ); vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')), @@ -966,6 +1079,9 @@ describe('gemini.tsx main function exit codes', () => { }), ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, resume: 'invalid-session', } as unknown as CliArgs); @@ -1056,7 +1172,11 @@ describe('gemini.tsx main function exit codes', () => { merged: { security: { auth: {} }, ui: {} }, }), ); - vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + } as unknown as CliArgs); // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = true; @@ -1091,7 +1211,11 @@ describe('gemini.tsx main function exit codes', () => { merged: { security: { auth: { selectedType: undefined } }, ui: {} }, }), ); - vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + } as unknown as CliArgs); runNonInteractiveSpy.mockImplementation(() => Promise.resolve()); @@ -1161,7 +1285,12 @@ describe('project hooks loading based on trust', () => { const configModule = await import('./config/config.js'); loadCliConfig = vi.mocked(configModule.loadCliConfig); parseArguments = vi.mocked(configModule.parseArguments); - parseArguments.mockResolvedValue({ startupMessages: [] }); + parseArguments.mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + startupMessages: [], + }); const settingsModule = await import('./config/settings.js'); loadSettings = vi.mocked(settingsModule.loadSettings); @@ -1246,6 +1375,7 @@ describe('startInteractiveUI', () => { getProjectRoot: () => '/root', getScreenReader: () => false, getDebugMode: () => false, + getUseAlternateBuffer: () => true, }); const mockSettings = { merged: { @@ -1265,6 +1395,7 @@ describe('startInteractiveUI', () => { const mockWorkspaceRoot = '/root'; const mockInitializationResult = { authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, @@ -1280,6 +1411,8 @@ describe('startInteractiveUI', () => { runExitCleanup: vi.fn(), registerSyncCleanup: vi.fn(), registerTelemetryConfig: vi.fn(), + setupSignalHandlers: vi.fn(), + setupTtyCheck: vi.fn(() => vi.fn()), })); beforeEach(() => { @@ -1386,7 +1519,8 @@ describe('startInteractiveUI', () => { // Verify all startup tasks were called expect(getVersion).toHaveBeenCalledTimes(1); - expect(registerCleanup).toHaveBeenCalledTimes(4); + // 5 cleanups: mouseEvents, consolePatcher, lineWrapping, instance.unmount, and TTY check + expect(registerCleanup).toHaveBeenCalledTimes(5); // Verify cleanup handler is registered with unmount function const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0]; @@ -1446,6 +1580,7 @@ describe('startInteractiveUI', () => { .spyOn(process.stdout, 'write') .mockImplementation(() => true); const mockConfigWithScreenReader = { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...mockConfig, getScreenReader: () => screenReader, } as Config; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 2fd30c0bf4..9f298af0db 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -4,24 +4,56 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { render } from 'ink'; -import { AppContainer } from './ui/AppContainer.js'; +import { + type StartupWarning, + WarningPriority, + type Config, + type ResumedSessionData, + type WorktreeInfo, + type OutputPayload, + type ConsoleLogPayload, + type UserFeedbackPayload, + sessionId, + logUserPrompt, + AuthType, + UserPromptEvent, + coreEvents, + CoreEvent, + getOauthClient, + patchStdio, + writeToStdout, + writeToStderr, + shouldEnterAlternateScreen, + startupProfiler, + ExitCodes, + SessionStartSource, + SessionEndReason, + ValidationCancelledError, + ValidationRequiredError, + type AdminControlsSettings, + debugLogger, + escapeShellArg, + getShellConfiguration, +} from '@google/gemini-cli-core'; + import { loadCliConfig, parseArguments } from './config/config.js'; import * as cliConfig from './config/config.js'; import { readStdin } from './utils/readStdin.js'; -import { basename } from 'node:path'; import { createHash } from 'node:crypto'; import v8 from 'node:v8'; import os from 'node:os'; import dns from 'node:dns'; import { start_sandbox } from './utils/sandbox.js'; -import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; +import { + loadSettings, + SettingScope, + type DnsResolutionOrder, + type LoadedSettings, +} from './config/settings.js'; import { loadTrustedFolders, type TrustedFoldersError, } from './config/trustedFolders.js'; -import { loadSettings, SettingScope } from './config/settings.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; @@ -32,66 +64,23 @@ import { registerSyncCleanup, runExitCleanup, registerTelemetryConfig, + setupSignalHandlers, } from './utils/cleanup.js'; +import { setupWorktree } from './utils/worktreeSetup.js'; import { cleanupToolOutputFiles, cleanupExpiredSessions, } from './utils/sessionCleanup.js'; -import { - type StartupWarning, - WarningPriority, - type Config, - type ResumedSessionData, - type OutputPayload, - type ConsoleLogPayload, - type UserFeedbackPayload, - sessionId, - logUserPrompt, - AuthType, - getOauthClient, - UserPromptEvent, - debugLogger, - recordSlowRender, - coreEvents, - CoreEvent, - createWorkingStdio, - patchStdio, - writeToStdout, - writeToStderr, - disableMouseEvents, - enableMouseEvents, - disableLineWrapping, - enableLineWrapping, - shouldEnterAlternateScreen, - startupProfiler, - ExitCodes, - SessionStartSource, - SessionEndReason, - getVersion, - ValidationCancelledError, - ValidationRequiredError, - type AdminControlsSettings, -} from '@google/gemini-cli-core'; import { initializeApp, type InitializationResult, } from './core/initializer.js'; import { validateAuthMethod } from './config/auth.js'; -import { runZedIntegration } from './zed-integration/zedIntegration.js'; +import { runAcpClient } from './acp/acpClient.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; -import { checkForUpdates } from './ui/utils/updateCheck.js'; -import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; -import { SessionSelector } from './utils/sessionUtils.js'; -import { SettingsContext } from './ui/contexts/SettingsContext.js'; -import { MouseProvider } from './ui/contexts/MouseContext.js'; -import { StreamingState } from './ui/types.js'; -import { computeTerminalTitle } from './utils/windowTitle.js'; +import { SessionError, SessionSelector } from './utils/sessionUtils.js'; -import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; -import { VimModeProvider } from './ui/contexts/VimModeContext.js'; -import { KeypressProvider } from './ui/contexts/KeypressContext.js'; -import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { relaunchAppInChildProcess, relaunchOnExitCode, @@ -99,18 +88,13 @@ import { import { loadSandboxConfig } from './config/sandboxConfig.js'; import { deleteSession, listSessions } from './utils/sessions.js'; import { createPolicyUpdater } from './config/policy.js'; -import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; -import { TerminalProvider } from './ui/contexts/TerminalContext.js'; -import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; -import { profiler } from './ui/components/DebugProfiler.js'; import { runDeferredCommand } from './deferred.js'; +import { cleanupBackgroundLogs } from './utils/logCleanup.js'; import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js'; -const SLOW_RENDER_MS = 200; - export function validateDnsResolutionOrder( order: string | undefined, ): DnsResolutionOrder { @@ -189,136 +173,16 @@ export async function startInteractiveUI( resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, ) { - // Never enter Ink alternate buffer mode when screen reader mode is enabled - // as there is no benefit of alternate buffer mode when using a screen reader - // and the Ink alternate buffer mode requires line wrapping harmful to - // screen readers. - const useAlternateBuffer = shouldEnterAlternateScreen( - isAlternateBufferEnabled(settings), - config.getScreenReader(), + // Dynamically import the heavy UI module so React/Ink are only parsed when needed + const { startInteractiveUI: doStartUI } = await import('./interactiveCli.js'); + await doStartUI( + config, + settings, + startupWarnings, + workspaceRoot, + resumedSessionData, + initializationResult, ); - const mouseEventsEnabled = useAlternateBuffer; - if (mouseEventsEnabled) { - enableMouseEvents(); - registerCleanup(() => { - disableMouseEvents(); - }); - } - - const version = await getVersion(); - setWindowTitle(basename(workspaceRoot), settings); - - const consolePatcher = new ConsolePatcher({ - onNewMessage: (msg) => { - coreEvents.emitConsoleLog(msg.type, msg.content); - }, - debugMode: config.getDebugMode(), - }); - consolePatcher.patch(); - registerCleanup(consolePatcher.cleanup); - - const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio(); - - const isShpool = !!process.env['SHPOOL_SESSION_NAME']; - - // Create wrapper component to use hooks inside render - const AppWrapper = () => { - useKittyKeyboardProtocol(); - - return ( - - - - - - - - - - - - - - - - - - ); - }; - - if (isShpool) { - // Wait a moment for shpool to stabilize terminal size and state. - // shpool is a persistence tool that restores terminal state by replaying it. - // This delay gives shpool time to finish its restoration replay and send - // the actual terminal size (often via an immediate SIGWINCH) before we - // render the first TUI frame. Without this, the first frame may be - // garbled or rendered at an incorrect size, which disabling incremental - // rendering alone cannot fix for the initial frame. - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - const instance = render( - process.env['DEBUG'] ? ( - - - - ) : ( - - ), - { - stdout: inkStdout, - stderr: inkStderr, - stdin: process.stdin, - exitOnCtrlC: false, - isScreenReaderEnabled: config.getScreenReader(), - onRender: ({ renderTime }: { renderTime: number }) => { - if (renderTime > SLOW_RENDER_MS) { - recordSlowRender(config, renderTime); - } - profiler.reportFrameRendered(); - }, - patchConsole: false, - alternateBuffer: useAlternateBuffer, - incrementalRendering: - settings.merged.ui.incrementalRendering !== false && - useAlternateBuffer && - !isShpool, - }, - ); - - if (useAlternateBuffer) { - disableLineWrapping(); - registerCleanup(() => { - enableLineWrapping(); - }); - } - - checkForUpdates(settings) - .then((info) => { - handleAutoUpdate(info, settings, config.getProjectRoot()); - }) - .catch((err) => { - // Silently ignore update check errors. - if (config.getDebugMode()) { - debugLogger.warn('Update check failed:', err); - } - }); - - registerCleanup(() => instance.unmount()); } export async function main() { @@ -340,6 +204,8 @@ export async function main() { setupUnhandledRejectionHandler(); + setupSignalHandlers(); + const slashCommandConflictHandler = new SlashCommandConflictHandler(); slashCommandConflictHandler.start(); registerCleanup(() => slashCommandConflictHandler.stop()); @@ -348,6 +214,13 @@ export async function main() { const settings = loadSettings(); loadSettingsHandle?.end(); + // If a worktree is requested and enabled, set it up early. + const requestedWorktree = cliConfig.getRequestedWorktreeName(settings); + let worktreeInfo: WorktreeInfo | undefined; + if (requestedWorktree !== undefined) { + worktreeInfo = await setupWorktree(requestedWorktree || undefined); + } + // Report settings errors once during startup settings.errors.forEach((error) => { coreEvents.emitFeedback('warning', error.message); @@ -364,6 +237,7 @@ export async function main() { await Promise.all([ cleanupCheckpoints(), cleanupToolOutputFiles(settings.merged), + cleanupBackgroundLogs(), ]); const parseArgsHandle = startupProfiler.start('parse_arguments'); @@ -563,6 +437,7 @@ export async function main() { const loadConfigHandle = startupProfiler.start('load_cli_config'); const config = await loadCliConfig(settings.merged, sessionId, argv, { projectHooks: settings.workspace.settings.hooks, + worktreeSettings: worktreeInfo, }); loadConfigHandle?.end(); @@ -646,10 +521,7 @@ export async function main() { process.stdin.setRawMode(true); // This cleanup isn't strictly needed but may help in certain situations. - process.on('SIGTERM', () => { - process.stdin.setRawMode(wasRaw); - }); - process.on('SIGINT', () => { + registerSyncCleanup(() => { process.stdin.setRawMode(wasRaw); }); } @@ -669,13 +541,13 @@ export async function main() { await getOauthClient(settings.merged.security.auth.selectedType, config); } - if (config.getExperimentalZedIntegration()) { - return runZedIntegration(config, settings, argv); + if (config.getAcpMode()) { + return runAcpClient(config, settings, argv); } let input = config.getQuestion(); const useAlternateBuffer = shouldEnterAlternateScreen( - isAlternateBufferEnabled(settings), + isAlternateBufferEnabled(config), config.getScreenReader(), ); const rawStartupWarnings = await getStartupWarnings(); @@ -698,9 +570,11 @@ export async function main() { const result = await sessionSelector.resolveSession(argv.resume); if (!config.isInteractive() && result.isOriginProjectMismatch) { const originalFolder = result.originProjectPath ?? 'unknown'; + const { shell } = getShellConfiguration(); + const rerunCommand = `cd ${escapeShellArg(originalFolder, shell)}\n gemini --resume ${escapeShellArg(result.sessionData.sessionId, shell)}`; coreEvents.emitFeedback( 'error', - `Cannot resume session ${result.sessionData.sessionId} from "${process.cwd()}" in non-interactive mode.\n Original folder: ${originalFolder}\n Rerun from the original folder:\n cd ${originalFolder}\n gemini --resume ${result.sessionData.sessionId}`, + `Cannot resume session ${result.sessionData.sessionId} from "${process.cwd()}" in non-interactive mode.\n Original folder: ${originalFolder}\n Rerun from the original folder:\n ${rerunCommand}`, ); await runExitCleanup(); process.exit(ExitCodes.FATAL_INPUT_ERROR); @@ -713,12 +587,24 @@ export async function main() { // Use the existing session ID to continue recording to the same session config.setSessionId(resumedSessionData.conversation.sessionId); } catch (error) { - coreEvents.emitFeedback( - 'error', - `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - await runExitCleanup(); - process.exit(ExitCodes.FATAL_INPUT_ERROR); + if ( + error instanceof SessionError && + error.code === 'NO_SESSIONS_FOUND' + ) { + // No sessions to resume — start a fresh session with a warning + startupWarnings.push({ + id: 'resume-no-sessions', + message: error.message, + priority: WarningPriority.High, + }); + } else { + coreEvents.emitFeedback( + 'error', + `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } } } @@ -785,7 +671,7 @@ export async function main() { process.exit(ExitCodes.FATAL_INPUT_ERROR); } - const prompt_id = Math.random().toString(16).slice(2); + const prompt_id = sessionId; logUserPrompt( config, new UserPromptEvent( @@ -823,25 +709,6 @@ export async function main() { } } -function setWindowTitle(title: string, settings: LoadedSettings) { - if (!settings.merged.ui.hideWindowTitle) { - // Initial state before React loop starts - const windowTitle = computeTerminalTitle({ - streamingState: StreamingState.Idle, - isConfirming: false, - isSilentWorking: false, - folderName: title, - showThoughts: !!settings.merged.ui.showStatusInTitle, - useDynamicTitle: settings.merged.ui.dynamicWindowTitle, - }); - writeToStdout(`\x1b]0;${windowTitle}\x07`); - - process.on('exit', () => { - writeToStdout(`\x1b]0;\x07`); - }); - } -} - export function initializeOutputListenersAndFlush() { // If there are no listeners for output, make sure we flush so output is not // lost. diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index fb37bb94ec..382ad3f81f 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -6,8 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { main } from './gemini.js'; -import { debugLogger } from '@google/gemini-cli-core'; -import { type Config } from '@google/gemini-cli-core'; +import { debugLogger, type Config } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -73,6 +72,8 @@ vi.mock('./config/config.js', () => ({ } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ @@ -179,7 +180,7 @@ describe('gemini.tsx main function cleanup', () => { vi.restoreAllMocks(); }); - it('should log error when cleanupExpiredSessions fails', async () => { + it.skip('should log error when cleanupExpiredSessions fails', async () => { const { loadCliConfig, parseArguments } = await import( './config/config.js' ); @@ -216,7 +217,7 @@ describe('gemini.tsx main function cleanup', () => { getMcpServers: () => ({}), getMcpClientManager: vi.fn(), getIdeMode: vi.fn(() => false), - getExperimentalZedIntegration: vi.fn(() => true), + getAcpMode: vi.fn(() => true), getScreenReader: vi.fn(() => false), getGeminiMdFileCount: vi.fn(() => 0), getProjectRoot: vi.fn(() => '/'), diff --git a/packages/cli/src/integration-tests/modelSteering.test.tsx b/packages/cli/src/integration-tests/modelSteering.test.tsx index ca1970cebc..bada268329 100644 --- a/packages/cli/src/integration-tests/modelSteering.test.tsx +++ b/packages/cli/src/integration-tests/modelSteering.test.tsx @@ -29,7 +29,7 @@ describe('Model Steering Integration', () => { configOverrides: { modelSteering: true }, }); await rig.initialize(); - rig.render(); + await rig.render(); await rig.waitForIdle(); rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER); @@ -65,10 +65,6 @@ describe('Model Steering Integration', () => { // Resolve list_directory (Proceed) await rig.resolveTool('ReadFolder'); - // Wait for the model to process the hint and output the next action - // Based on steering.responses, it should first acknowledge the hint - await rig.waitForOutput('ACK: I will focus on .txt files now.'); - // Then it should proceed with the next action await rig.waitForOutput( /Since you want me to focus on .txt files,[\s\S]*I will read file1.txt/, diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx new file mode 100644 index 0000000000..a6337ef29c --- /dev/null +++ b/packages/cli/src/interactiveCli.tsx @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'ink'; +import { basename } from 'node:path'; +import { AppContainer } from './ui/AppContainer.js'; +import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; +import { registerCleanup, setupTtyCheck } from './utils/cleanup.js'; +import { + type StartupWarning, + type Config, + type ResumedSessionData, + coreEvents, + createWorkingStdio, + disableMouseEvents, + enableMouseEvents, + disableLineWrapping, + enableLineWrapping, + shouldEnterAlternateScreen, + recordSlowRender, + writeToStdout, + getVersion, + debugLogger, +} from '@google/gemini-cli-core'; +import type { InitializationResult } from './core/initializer.js'; +import type { LoadedSettings } from './config/settings.js'; +import { checkForUpdates } from './ui/utils/updateCheck.js'; +import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; +import { SettingsContext } from './ui/contexts/SettingsContext.js'; +import { MouseProvider } from './ui/contexts/MouseContext.js'; +import { StreamingState } from './ui/types.js'; +import { computeTerminalTitle } from './utils/windowTitle.js'; + +import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; +import { VimModeProvider } from './ui/contexts/VimModeContext.js'; +import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js'; +import { loadKeyMatchers } from './ui/key/keyMatchers.js'; +import { KeypressProvider } from './ui/contexts/KeypressContext.js'; +import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; +import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; +import { TerminalProvider } from './ui/contexts/TerminalContext.js'; +import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; +import { OverflowProvider } from './ui/contexts/OverflowContext.js'; +import { profiler } from './ui/components/DebugProfiler.js'; + +const SLOW_RENDER_MS = 200; + +export async function startInteractiveUI( + config: Config, + settings: LoadedSettings, + startupWarnings: StartupWarning[], + workspaceRoot: string = process.cwd(), + resumedSessionData: ResumedSessionData | undefined, + initializationResult: InitializationResult, +) { + // Never enter Ink alternate buffer mode when screen reader mode is enabled + // as there is no benefit of alternate buffer mode when using a screen reader + // and the Ink alternate buffer mode requires line wrapping harmful to + // screen readers. + const useAlternateBuffer = shouldEnterAlternateScreen( + isAlternateBufferEnabled(config), + config.getScreenReader(), + ); + const mouseEventsEnabled = useAlternateBuffer; + if (mouseEventsEnabled) { + enableMouseEvents(); + registerCleanup(() => { + disableMouseEvents(); + }); + } + + const { matchers, errors } = await loadKeyMatchers(); + errors.forEach((error) => { + coreEvents.emitFeedback('warning', error); + }); + + const version = await getVersion(); + setWindowTitle(basename(workspaceRoot), settings); + + const consolePatcher = new ConsolePatcher({ + onNewMessage: (msg) => { + coreEvents.emitConsoleLog(msg.type, msg.content); + }, + debugMode: config.getDebugMode(), + }); + consolePatcher.patch(); + registerCleanup(consolePatcher.cleanup); + + const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio(); + + const isShpool = !!process.env['SHPOOL_SESSION_NAME']; + + // Create wrapper component to use hooks inside render + const AppWrapper = () => { + useKittyKeyboardProtocol(); + + return ( + + + + + + + + + + + + + + + + + + + + ); + }; + + if (isShpool) { + // Wait a moment for shpool to stabilize terminal size and state. + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const instance = render( + process.env['DEBUG'] ? ( + + + + ) : ( + + ), + { + stdout: inkStdout, + stderr: inkStderr, + stdin: process.stdin, + exitOnCtrlC: false, + isScreenReaderEnabled: config.getScreenReader(), + onRender: ({ renderTime }: { renderTime: number }) => { + if (renderTime > SLOW_RENDER_MS) { + recordSlowRender(config, renderTime); + } + profiler.reportFrameRendered(); + }, + patchConsole: false, + alternateBuffer: useAlternateBuffer, + incrementalRendering: + settings.merged.ui.incrementalRendering !== false && + useAlternateBuffer && + !isShpool, + }, + ); + + if (useAlternateBuffer) { + disableLineWrapping(); + registerCleanup(() => { + enableLineWrapping(); + }); + } + + checkForUpdates(settings) + .then((info) => { + handleAutoUpdate(info, settings, config.getProjectRoot()); + }) + .catch((err) => { + // Silently ignore update check errors. + if (config.getDebugMode()) { + debugLogger.warn('Update check failed:', err); + } + }); + + registerCleanup(() => instance.unmount()); + + registerCleanup(setupTtyCheck()); +} + +function setWindowTitle(title: string, settings: LoadedSettings) { + if (!settings.merged.ui.hideWindowTitle) { + // Initial state before React loop starts + const windowTitle = computeTerminalTitle({ + streamingState: StreamingState.Idle, + isConfirming: false, + isSilentWorking: false, + folderName: title, + showThoughts: !!settings.merged.ui.showStatusInTitle, + useDynamicTitle: settings.merged.ui.dynamicWindowTitle, + }); + writeToStdout(`\x1b]0;${windowTitle}\x07`); + + process.on('exit', () => { + writeToStdout(`\x1b]0;\x07`); + }); + } +} diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 206d011e63..4e45b0f188 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -1137,6 +1137,7 @@ describe('runNonInteractive', () => { expect( processStderrSpy.mock.calls.some( + // eslint-disable-next-line no-restricted-syntax (call) => typeof call[0] === 'string' && call[0].includes('Cancelling'), ), ).toBe(true); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index c2cab72353..891e3d0ee9 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -211,7 +211,7 @@ export async function runNonInteractive({ const geminiClient = config.getGeminiClient(); const scheduler = new Scheduler({ - config, + context: config, messageBus: config.getMessageBus(), getPreferredEditor: () => undefined, schedulerId: ROOT_SCHEDULER_ID, @@ -263,8 +263,8 @@ export async function runNonInteractive({ onDebugMessage: () => {}, messageId: Date.now(), signal: abortController.signal, + escapePastedAtSymbols: false, }); - if (error || !processedQuery) { // An error occurred during @include processing (e.g., file not found). // The error message is already logged by handleAtCommand. diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index e09db71312..35cf5105ab 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -65,9 +65,9 @@ export const handleSlashCommand = async ( const logger = new Logger(config?.getSessionId() || '', config?.storage); - const context: CommandContext = { + const commandContext: CommandContext = { services: { - config, + agentContext: config, settings, git: undefined, logger, @@ -84,7 +84,7 @@ export const handleSlashCommand = async ( }, }; - const result = await commandToExecute.action(context, args); + const result = await commandToExecute.action(commandContext, args); if (result) { switch (result.type) { diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 1246ee0532..f166c161cd 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -52,8 +52,7 @@ vi.mock('../ui/commands/permissionsCommand.js', async () => { import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { BuiltinCommandLoader } from './BuiltinCommandLoader.js'; -import type { Config } from '@google/gemini-cli-core'; -import { isNightly } from '@google/gemini-cli-core'; +import { isNightly, type Config } from '@google/gemini-cli-core'; import { CommandKind } from '../ui/commands/types.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; @@ -73,7 +72,17 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({ })); vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); vi.mock('../ui/commands/chatCommand.js', () => ({ - chatCommand: { name: 'chat', subCommands: [] }, + chatCommand: { + name: 'chat', + subCommands: [ + { name: 'list' }, + { name: 'save' }, + { name: 'resume' }, + { name: 'delete' }, + { name: 'share' }, + { name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] }, + ], + }, debugCommand: { name: 'debug' }, })); vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); @@ -94,7 +103,19 @@ vi.mock('../ui/commands/modelCommand.js', () => ({ })); vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} })); vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} })); -vi.mock('../ui/commands/resumeCommand.js', () => ({ resumeCommand: {} })); +vi.mock('../ui/commands/resumeCommand.js', () => ({ + resumeCommand: { + name: 'resume', + subCommands: [ + { name: 'list' }, + { name: 'save' }, + { name: 'resume' }, + { name: 'delete' }, + { name: 'share' }, + { name: 'checkpoints', hidden: true, subCommands: [{ name: 'list' }] }, + ], + }, +})); vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} })); vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} })); vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} })); @@ -120,6 +141,14 @@ vi.mock('../ui/commands/mcpCommand.js', () => ({ }, })); +vi.mock('../ui/commands/upgradeCommand.js', () => ({ + upgradeCommand: { + name: 'upgrade', + description: 'Upgrade command', + kind: 'BUILT_IN', + }, +})); + describe('BuiltinCommandLoader', () => { let mockConfig: Config; @@ -129,7 +158,7 @@ describe('BuiltinCommandLoader', () => { vi.clearAllMocks(); mockConfig = { getFolderTrust: vi.fn().mockReturnValue(true), - isPlanEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), getEnableExtensionReloading: () => false, getEnableHooks: () => false, getEnableHooksUI: () => false, @@ -141,6 +170,9 @@ describe('BuiltinCommandLoader', () => { getAllSkills: vi.fn().mockReturnValue([]), isAdminEnabled: vi.fn().mockReturnValue(true), }), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: 'other', + }), } as unknown as Config; restoreCommandMock.mockReturnValue({ @@ -150,6 +182,27 @@ describe('BuiltinCommandLoader', () => { }); }); + it('should include upgrade command when authType is login_with_google', async () => { + const { AuthType } = await import('@google/gemini-cli-core'); + (mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const upgradeCmd = commands.find((c) => c.name === 'upgrade'); + expect(upgradeCmd).toBeDefined(); + }); + + it('should exclude upgrade command when authType is NOT login_with_google', async () => { + (mockConfig.getContentGeneratorConfig as Mock).mockReturnValue({ + authType: 'other', + }); + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const upgradeCmd = commands.find((c) => c.name === 'upgrade'); + expect(upgradeCmd).toBeUndefined(); + }); + it('should correctly pass the config object to restore command factory', async () => { const loader = new BuiltinCommandLoader(mockConfig); await loader.loadCommands(new AbortController().signal); @@ -213,6 +266,7 @@ describe('BuiltinCommandLoader', () => { it('should include policies command when message bus integration is enabled', async () => { const mockConfigWithMessageBus = { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...mockConfig, getEnableHooks: () => false, getMcpEnabled: () => true, @@ -256,7 +310,7 @@ describe('BuiltinCommandLoader', () => { }); describe('chat debug command', () => { - it('should NOT add debug subcommand to chatCommand if not a nightly build', async () => { + it('should NOT add debug subcommand to chat/resume commands if not a nightly build', async () => { vi.mocked(isNightly).mockResolvedValue(false); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); @@ -265,9 +319,30 @@ describe('BuiltinCommandLoader', () => { expect(chatCmd?.subCommands).toBeDefined(); const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); expect(hasDebug).toBe(false); + + const resumeCmd = commands.find((c) => c.name === 'resume'); + const resumeHasDebug = + resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; + expect(resumeHasDebug).toBe(false); + + const chatCheckpointsCmd = chatCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const chatCheckpointHasDebug = + chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(chatCheckpointHasDebug).toBe(false); + + const resumeCheckpointsCmd = resumeCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const resumeCheckpointHasDebug = + resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(resumeCheckpointHasDebug).toBe(false); }); - it('should add debug subcommand to chatCommand if it is a nightly build', async () => { + it('should add debug subcommand to chat/resume commands if it is a nightly build', async () => { vi.mocked(isNightly).mockResolvedValue(true); const loader = new BuiltinCommandLoader(mockConfig); const commands = await loader.loadCommands(new AbortController().signal); @@ -276,6 +351,27 @@ describe('BuiltinCommandLoader', () => { expect(chatCmd?.subCommands).toBeDefined(); const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug'); expect(hasDebug).toBe(true); + + const resumeCmd = commands.find((c) => c.name === 'resume'); + const resumeHasDebug = + resumeCmd?.subCommands?.some((c) => c.name === 'debug') ?? false; + expect(resumeHasDebug).toBe(true); + + const chatCheckpointsCmd = chatCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const chatCheckpointHasDebug = + chatCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(chatCheckpointHasDebug).toBe(true); + + const resumeCheckpointsCmd = resumeCmd?.subCommands?.find( + (c) => c.name === 'checkpoints', + ); + const resumeCheckpointHasDebug = + resumeCheckpointsCmd?.subCommands?.some((c) => c.name === 'debug') ?? + false; + expect(resumeCheckpointHasDebug).toBe(true); }); }); }); @@ -287,7 +383,7 @@ describe('BuiltinCommandLoader profile', () => { vi.resetModules(); mockConfig = { getFolderTrust: vi.fn().mockReturnValue(false), - isPlanEnabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(true), getCheckpointingEnabled: () => false, getEnableExtensionReloading: () => false, getEnableHooks: () => false, @@ -300,6 +396,9 @@ describe('BuiltinCommandLoader profile', () => { getAllSkills: vi.fn().mockReturnValue([]), isAdminEnabled: vi.fn().mockReturnValue(true), }), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: 'other', + }), } as unknown as Config; }); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 31673e921a..66806f5ef1 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -16,6 +16,7 @@ import { isNightly, startupProfiler, getAdminErrorMessage, + AuthType, } from '@google/gemini-cli-core'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js'; @@ -31,6 +32,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; +import { footerCommand } from '../ui/commands/footerCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js'; import { rewindCommand } from '../ui/commands/rewindCommand.js'; @@ -58,6 +60,7 @@ import { shellsCommand } from '../ui/commands/shellsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; +import { upgradeCommand } from '../ui/commands/upgradeCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -77,6 +80,41 @@ export class BuiltinCommandLoader implements ICommandLoader { const handle = startupProfiler.start('load_builtin_commands'); const isNightlyBuild = await isNightly(process.cwd()); + const addDebugToChatResumeSubCommands = ( + subCommands: SlashCommand[] | undefined, + ): SlashCommand[] | undefined => { + if (!subCommands) { + return subCommands; + } + + const withNestedCompatibility = subCommands.map((subCommand) => { + if (subCommand.name !== 'checkpoints') { + return subCommand; + } + + return { + ...subCommand, + subCommands: addDebugToChatResumeSubCommands(subCommand.subCommands), + }; + }); + + if (!isNightlyBuild) { + return withNestedCompatibility; + } + + return withNestedCompatibility.some( + (cmd) => cmd.name === debugCommand.name, + ) + ? withNestedCompatibility + : [ + ...withNestedCompatibility, + { ...debugCommand, suggestionGroup: 'checkpoints' }, + ]; + }; + + const chatResumeSubCommands = addDebugToChatResumeSubCommands( + chatCommand.subCommands, + ); const allDefinitions: Array = [ aboutCommand, @@ -85,9 +123,7 @@ export class BuiltinCommandLoader implements ICommandLoader { bugCommand, { ...chatCommand, - subCommands: isNightlyBuild - ? [...(chatCommand.subCommands || []), debugCommand] - : chatCommand.subCommands, + subCommands: chatResumeSubCommands, }, clearCommand, commandsCommand, @@ -119,6 +155,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ] : [extensionsCommand(this.config?.getEnableExtensionReloading())]), helpCommand, + footerCommand, shortcutsCommand, ...(this.config?.getEnableHooksUI() ? [hooksCommand] : []), rewindCommand, @@ -153,7 +190,10 @@ export class BuiltinCommandLoader implements ICommandLoader { ...(isDevelopment ? [profileCommand] : []), quitCommand, restoreCommand(this.config), - resumeCommand, + { + ...resumeCommand, + subCommands: addDebugToChatResumeSubCommands(resumeCommand.subCommands), + }, statsCommand, themeCommand, toolsCommand, @@ -185,6 +225,10 @@ export class BuiltinCommandLoader implements ICommandLoader { vimCommand, setupGithubCommand, terminalSetupCommand, + ...(this.config?.getContentGeneratorConfig()?.authType === + AuthType.LOGIN_WITH_GOOGLE + ? [upgradeCommand] + : []), ]; handle?.end(); return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index ea906a3da6..eae7ec7c40 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -17,21 +17,9 @@ const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ action: vi.fn(), }); -const mockCommandA = createMockCommand('command-a', CommandKind.BUILT_IN); -const mockCommandB = createMockCommand('command-b', CommandKind.BUILT_IN); -const mockCommandC = createMockCommand('command-c', CommandKind.FILE); -const mockCommandB_Override = createMockCommand('command-b', CommandKind.FILE); - class MockCommandLoader implements ICommandLoader { - private commandsToLoad: SlashCommand[]; - - constructor(commandsToLoad: SlashCommand[]) { - this.commandsToLoad = commandsToLoad; - } - - loadCommands = vi.fn( - async (): Promise => Promise.resolve(this.commandsToLoad), - ); + constructor(private readonly commands: SlashCommand[]) {} + loadCommands = vi.fn(async () => Promise.resolve(this.commands)); } describe('CommandService', () => { @@ -43,424 +31,74 @@ describe('CommandService', () => { vi.restoreAllMocks(); }); - it('should load commands from a single loader', async () => { - const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]); - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); + describe('basic loading', () => { + it('should aggregate commands from multiple successful loaders', async () => { + const cmdA = createMockCommand('a', CommandKind.BUILT_IN); + const cmdB = createMockCommand('b', CommandKind.USER_FILE); + const service = await CommandService.create( + [new MockCommandLoader([cmdA]), new MockCommandLoader([cmdB])], + new AbortController().signal, + ); - const commands = service.getCommands(); + expect(service.getCommands()).toHaveLength(2); + expect(service.getCommands()).toEqual( + expect.arrayContaining([cmdA, cmdB]), + ); + }); - expect(mockLoader.loadCommands).toHaveBeenCalledTimes(1); - expect(commands).toHaveLength(2); - expect(commands).toEqual( - expect.arrayContaining([mockCommandA, mockCommandB]), - ); - }); + it('should handle empty loaders and failed loaders gracefully', async () => { + const cmdA = createMockCommand('a', CommandKind.BUILT_IN); + const failingLoader = new MockCommandLoader([]); + vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue( + new Error('fail'), + ); - it('should aggregate commands from multiple loaders', async () => { - const loader1 = new MockCommandLoader([mockCommandA]); - const loader2 = new MockCommandLoader([mockCommandC]); - const service = await CommandService.create( - [loader1, loader2], - new AbortController().signal, - ); + const service = await CommandService.create( + [ + new MockCommandLoader([cmdA]), + new MockCommandLoader([]), + failingLoader, + ], + new AbortController().signal, + ); - const commands = service.getCommands(); + expect(service.getCommands()).toHaveLength(1); + expect(service.getCommands()[0].name).toBe('a'); + expect(debugLogger.debug).toHaveBeenCalledWith( + 'A command loader failed:', + expect.any(Error), + ); + }); - expect(loader1.loadCommands).toHaveBeenCalledTimes(1); - expect(loader2.loadCommands).toHaveBeenCalledTimes(1); - expect(commands).toHaveLength(2); - expect(commands).toEqual( - expect.arrayContaining([mockCommandA, mockCommandC]), - ); - }); + it('should return a readonly array of commands', async () => { + const service = await CommandService.create( + [new MockCommandLoader([createMockCommand('a', CommandKind.BUILT_IN)])], + new AbortController().signal, + ); + expect(() => (service.getCommands() as unknown[]).push({})).toThrow(); + }); - it('should override commands from earlier loaders with those from later loaders', async () => { - const loader1 = new MockCommandLoader([mockCommandA, mockCommandB]); - const loader2 = new MockCommandLoader([ - mockCommandB_Override, - mockCommandC, - ]); - const service = await CommandService.create( - [loader1, loader2], - new AbortController().signal, - ); - - const commands = service.getCommands(); - - expect(commands).toHaveLength(3); // Should be A, C, and the overridden B. - - // The final list should contain the override from the *last* loader. - const commandB = commands.find((cmd) => cmd.name === 'command-b'); - expect(commandB).toBeDefined(); - expect(commandB?.kind).toBe(CommandKind.FILE); // Verify it's the overridden version. - expect(commandB).toEqual(mockCommandB_Override); - - // Ensure the other commands are still present. - expect(commands).toEqual( - expect.arrayContaining([ - mockCommandA, - mockCommandC, - mockCommandB_Override, - ]), - ); - }); - - it('should handle loaders that return an empty array of commands gracefully', async () => { - const loader1 = new MockCommandLoader([mockCommandA]); - const emptyLoader = new MockCommandLoader([]); - const loader3 = new MockCommandLoader([mockCommandB]); - const service = await CommandService.create( - [loader1, emptyLoader, loader3], - new AbortController().signal, - ); - - const commands = service.getCommands(); - - expect(emptyLoader.loadCommands).toHaveBeenCalledTimes(1); - expect(commands).toHaveLength(2); - expect(commands).toEqual( - expect.arrayContaining([mockCommandA, mockCommandB]), - ); - }); - - it('should load commands from successful loaders even if one fails', async () => { - const successfulLoader = new MockCommandLoader([mockCommandA]); - const failingLoader = new MockCommandLoader([]); - const error = new Error('Loader failed'); - vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue(error); - - const service = await CommandService.create( - [successfulLoader, failingLoader], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(1); - expect(commands).toEqual([mockCommandA]); - expect(debugLogger.debug).toHaveBeenCalledWith( - 'A command loader failed:', - error, - ); - }); - - it('getCommands should return a readonly array that cannot be mutated', async () => { - const service = await CommandService.create( - [new MockCommandLoader([mockCommandA])], - new AbortController().signal, - ); - - const commands = service.getCommands(); - - // Expect it to throw a TypeError at runtime because the array is frozen. - expect(() => { - // @ts-expect-error - Testing immutability is intentional here. - commands.push(mockCommandB); - }).toThrow(); - - // Verify the original array was not mutated. - expect(service.getCommands()).toHaveLength(1); - }); - - it('should pass the abort signal to all loaders', async () => { - const controller = new AbortController(); - const signal = controller.signal; - - const loader1 = new MockCommandLoader([mockCommandA]); - const loader2 = new MockCommandLoader([mockCommandB]); - - await CommandService.create([loader1, loader2], signal); - - expect(loader1.loadCommands).toHaveBeenCalledTimes(1); - expect(loader1.loadCommands).toHaveBeenCalledWith(signal); - expect(loader2.loadCommands).toHaveBeenCalledTimes(1); - expect(loader2.loadCommands).toHaveBeenCalledWith(signal); - }); - - it('should rename extension commands when they conflict', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const userCommand = createMockCommand('sync', CommandKind.FILE); - const extensionCommand1 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'firebase', - description: '[firebase] Deploy to Firebase', - }; - const extensionCommand2 = { - ...createMockCommand('sync', CommandKind.FILE), - extensionName: 'git-helper', - description: '[git-helper] Sync with remote', - }; - - const mockLoader1 = new MockCommandLoader([builtinCommand]); - const mockLoader2 = new MockCommandLoader([ - userCommand, - extensionCommand1, - extensionCommand2, - ]); - - const service = await CommandService.create( - [mockLoader1, mockLoader2], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(4); - - // Built-in command keeps original name - const deployBuiltin = commands.find( - (cmd) => cmd.name === 'deploy' && !cmd.extensionName, - ); - expect(deployBuiltin).toBeDefined(); - expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN); - - // Extension command conflicting with built-in gets renamed - const deployExtension = commands.find( - (cmd) => cmd.name === 'firebase.deploy', - ); - expect(deployExtension).toBeDefined(); - expect(deployExtension?.extensionName).toBe('firebase'); - - // User command keeps original name - const syncUser = commands.find( - (cmd) => cmd.name === 'sync' && !cmd.extensionName, - ); - expect(syncUser).toBeDefined(); - expect(syncUser?.kind).toBe(CommandKind.FILE); - - // Extension command conflicting with user command gets renamed - const syncExtension = commands.find( - (cmd) => cmd.name === 'git-helper.sync', - ); - expect(syncExtension).toBeDefined(); - expect(syncExtension?.extensionName).toBe('git-helper'); - }); - - it('should handle user/project command override correctly', async () => { - const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN); - const userCommand = createMockCommand('help', CommandKind.FILE); - const projectCommand = createMockCommand('deploy', CommandKind.FILE); - const userDeployCommand = createMockCommand('deploy', CommandKind.FILE); - - const mockLoader1 = new MockCommandLoader([builtinCommand]); - const mockLoader2 = new MockCommandLoader([ - userCommand, - userDeployCommand, - projectCommand, - ]); - - const service = await CommandService.create( - [mockLoader1, mockLoader2], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(2); - - // User command overrides built-in - const helpCommand = commands.find((cmd) => cmd.name === 'help'); - expect(helpCommand).toBeDefined(); - expect(helpCommand?.kind).toBe(CommandKind.FILE); - - // Project command overrides user command (last wins) - const deployCommand = commands.find((cmd) => cmd.name === 'deploy'); - expect(deployCommand).toBeDefined(); - expect(deployCommand?.kind).toBe(CommandKind.FILE); - }); - - it('should handle secondary conflicts when renaming extension commands', async () => { - // User has both /deploy and /gcp.deploy commands - const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); - - // Extension also has a deploy command that will conflict with user's /deploy - const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', - }; - - const mockLoader = new MockCommandLoader([ - userCommand1, - userCommand2, - extensionCommand, - ]); - - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(3); - - // Original user command keeps its name - const deployUser = commands.find( - (cmd) => cmd.name === 'deploy' && !cmd.extensionName, - ); - expect(deployUser).toBeDefined(); - - // User's dot notation command keeps its name - const gcpDeployUser = commands.find( - (cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName, - ); - expect(gcpDeployUser).toBeDefined(); - - // Extension command gets renamed with suffix due to secondary conflict - const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp', - ); - expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); - }); - - it('should handle multiple secondary conflicts with incrementing suffixes', async () => { - // User has /deploy, /gcp.deploy, and /gcp.deploy1 - const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); - const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE); - - // Extension has a deploy command - const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', - }; - - const mockLoader = new MockCommandLoader([ - userCommand1, - userCommand2, - userCommand3, - extensionCommand, - ]); - - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(4); - - // Extension command gets renamed with suffix 2 due to multiple conflicts - const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp', - ); - expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); - }); - - it('should report conflicts via getConflicts', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'firebase', - }; - - const mockLoader = new MockCommandLoader([ - builtinCommand, - extensionCommand, - ]); - - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const conflicts = service.getConflicts(); - expect(conflicts).toHaveLength(1); - - expect(conflicts[0]).toMatchObject({ - name: 'deploy', - winner: builtinCommand, - losers: [ - { - renamedTo: 'firebase.deploy', - command: expect.objectContaining({ - name: 'deploy', - extensionName: 'firebase', - }), - }, - ], + it('should pass the abort signal to all loaders', async () => { + const controller = new AbortController(); + const loader = new MockCommandLoader([]); + await CommandService.create([loader], controller.signal); + expect(loader.loadCommands).toHaveBeenCalledWith(controller.signal); }); }); - it('should report extension vs extension conflicts correctly', async () => { - // Both extensions try to register 'deploy' - const extension1Command = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'firebase', - }; - const extension2Command = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'aws', - }; + describe('conflict delegation', () => { + it('should delegate conflict resolution to SlashCommandResolver', async () => { + const builtin = createMockCommand('help', CommandKind.BUILT_IN); + const user = createMockCommand('help', CommandKind.USER_FILE); - const mockLoader = new MockCommandLoader([ - extension1Command, - extension2Command, - ]); + const service = await CommandService.create( + [new MockCommandLoader([builtin, user])], + new AbortController().signal, + ); - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const conflicts = service.getConflicts(); - expect(conflicts).toHaveLength(1); - - expect(conflicts[0]).toMatchObject({ - name: 'deploy', - winner: expect.objectContaining({ - name: 'deploy', - extensionName: 'firebase', - }), - losers: [ - { - renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list - command: expect.objectContaining({ - name: 'deploy', - extensionName: 'aws', - }), - }, - ], + expect(service.getCommands().map((c) => c.name)).toContain('help'); + expect(service.getCommands().map((c) => c.name)).toContain('user.help'); + expect(service.getConflicts()).toHaveLength(1); }); }); - - it('should report multiple conflicts for the same command name', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const ext1 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext1', - }; - const ext2 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext2', - }; - - const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]); - - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const conflicts = service.getConflicts(); - expect(conflicts).toHaveLength(1); - expect(conflicts[0].name).toBe('deploy'); - expect(conflicts[0].losers).toHaveLength(2); - expect(conflicts[0].losers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - renamedTo: 'ext1.deploy', - command: expect.objectContaining({ extensionName: 'ext1' }), - }), - expect.objectContaining({ - renamedTo: 'ext2.deploy', - command: expect.objectContaining({ extensionName: 'ext2' }), - }), - ]), - ); - }); }); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index bd42226a32..61f9457619 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -6,16 +6,8 @@ import { debugLogger, coreEvents } from '@google/gemini-cli-core'; import type { SlashCommand } from '../ui/commands/types.js'; -import type { ICommandLoader } from './types.js'; - -export interface CommandConflict { - name: string; - winner: SlashCommand; - losers: Array<{ - command: SlashCommand; - renamedTo: string; - }>; -} +import type { ICommandLoader, CommandConflict } from './types.js'; +import { SlashCommandResolver } from './SlashCommandResolver.js'; /** * Orchestrates the discovery and loading of all slash commands for the CLI. @@ -24,9 +16,9 @@ export interface CommandConflict { * with an array of `ICommandLoader` instances, each responsible for fetching * commands from a specific source (e.g., built-in code, local files). * - * The CommandService is responsible for invoking these loaders, aggregating their - * results, and resolving any name conflicts. This architecture allows the command - * system to be extended with new sources without modifying the service itself. + * It uses a delegating resolver to reconcile name conflicts, ensuring that + * all commands are uniquely addressable via source-specific prefixes while + * allowing built-in commands to retain their primary names. */ export class CommandService { /** @@ -42,96 +34,71 @@ export class CommandService { /** * Asynchronously creates and initializes a new CommandService instance. * - * This factory method orchestrates the entire command loading process. It - * runs all provided loaders in parallel, aggregates their results, handles - * name conflicts for extension commands by renaming them, and then returns a - * fully constructed `CommandService` instance. + * This factory method orchestrates the loading process and delegates + * conflict resolution to the SlashCommandResolver. * - * Conflict resolution: - * - Extension commands that conflict with existing commands are renamed to - * `extensionName.commandName` - * - Non-extension commands (built-in, user, project) override earlier commands - * with the same name based on loader order - * - * @param loaders An array of objects that conform to the `ICommandLoader` - * interface. Built-in commands should come first, followed by FileCommandLoader. - * @param signal An AbortSignal to cancel the loading process. - * @returns A promise that resolves to a new, fully initialized `CommandService` instance. + * @param loaders An array of loaders to fetch commands from. + * @param signal An AbortSignal to allow cancellation. + * @returns A promise that resolves to a fully initialized CommandService. */ static async create( loaders: ICommandLoader[], signal: AbortSignal, ): Promise { + const allCommands = await this.loadAllCommands(loaders, signal); + const { finalCommands, conflicts } = + SlashCommandResolver.resolve(allCommands); + + if (conflicts.length > 0) { + this.emitConflictEvents(conflicts); + } + + return new CommandService( + Object.freeze(finalCommands), + Object.freeze(conflicts), + ); + } + + /** + * Invokes all loaders in parallel and flattens the results. + */ + private static async loadAllCommands( + loaders: ICommandLoader[], + signal: AbortSignal, + ): Promise { const results = await Promise.allSettled( loaders.map((loader) => loader.loadCommands(signal)), ); - const allCommands: SlashCommand[] = []; + const commands: SlashCommand[] = []; for (const result of results) { if (result.status === 'fulfilled') { - allCommands.push(...result.value); + commands.push(...result.value); } else { debugLogger.debug('A command loader failed:', result.reason); } } + return commands; + } - const commandMap = new Map(); - const conflictsMap = new Map(); - - for (const cmd of allCommands) { - let finalName = cmd.name; - - // Extension commands get renamed if they conflict with existing commands - if (cmd.extensionName && commandMap.has(cmd.name)) { - const winner = commandMap.get(cmd.name)!; - let renamedName = `${cmd.extensionName}.${cmd.name}`; - let suffix = 1; - - // Keep trying until we find a name that doesn't conflict - while (commandMap.has(renamedName)) { - renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`; - suffix++; - } - - finalName = renamedName; - - if (!conflictsMap.has(cmd.name)) { - conflictsMap.set(cmd.name, { - name: cmd.name, - winner, - losers: [], - }); - } - - conflictsMap.get(cmd.name)!.losers.push({ - command: cmd, - renamedTo: finalName, - }); - } - - commandMap.set(finalName, { - ...cmd, - name: finalName, - }); - } - - const conflicts = Array.from(conflictsMap.values()); - if (conflicts.length > 0) { - coreEvents.emitSlashCommandConflicts( - conflicts.flatMap((c) => - c.losers.map((l) => ({ - name: c.name, - renamedTo: l.renamedTo, - loserExtensionName: l.command.extensionName, - winnerExtensionName: c.winner.extensionName, - })), - ), - ); - } - - const finalCommands = Object.freeze(Array.from(commandMap.values())); - const finalConflicts = Object.freeze(conflicts); - return new CommandService(finalCommands, finalConflicts); + /** + * Formats and emits telemetry for command conflicts. + */ + private static emitConflictEvents(conflicts: CommandConflict[]): void { + coreEvents.emitSlashCommandConflicts( + conflicts.flatMap((c) => + c.losers.map((l) => ({ + name: c.name, + renamedTo: l.renamedTo, + loserExtensionName: l.command.extensionName, + winnerExtensionName: l.reason.extensionName, + loserMcpServerName: l.command.mcpServerName, + winnerMcpServerName: l.reason.mcpServerName, + loserKind: l.command.kind, + winnerKind: l.reason.kind, + })), + ), + ); } /** diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index 077b8c45fe..f3f8c2df94 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -6,8 +6,7 @@ import * as glob from 'glob'; import * as path from 'node:path'; -import type { Config } from '@google/gemini-cli-core'; -import { GEMINI_DIR, Storage } from '@google/gemini-cli-core'; +import { GEMINI_DIR, Storage, type Config } from '@google/gemini-cli-core'; import mock from 'mock-fs'; import { FileCommandLoader } from './FileCommandLoader.js'; import { assert, vi } from 'vitest'; diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index fb27327ead..7321837c93 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -9,8 +9,7 @@ import path from 'node:path'; import toml from '@iarna/toml'; import { glob } from 'glob'; import { z } from 'zod'; -import type { Config } from '@google/gemini-cli-core'; -import { Storage, coreEvents } from '@google/gemini-cli-core'; +import { Storage, coreEvents, type Config } from '@google/gemini-cli-core'; import type { ICommandLoader } from './types.js'; import type { CommandContext, @@ -37,6 +36,7 @@ import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; interface CommandDirectory { path: string; + kind: CommandKind; extensionName?: string; extensionId?: string; } @@ -111,6 +111,7 @@ export class FileCommandLoader implements ICommandLoader { this.parseAndAdaptFile( path.join(dirInfo.path, file), dirInfo.path, + dirInfo.kind, dirInfo.extensionName, dirInfo.extensionId, ), @@ -151,10 +152,16 @@ export class FileCommandLoader implements ICommandLoader { const storage = this.config?.storage ?? new Storage(this.projectRoot); // 1. User commands - dirs.push({ path: Storage.getUserCommandsDir() }); + dirs.push({ + path: Storage.getUserCommandsDir(), + kind: CommandKind.USER_FILE, + }); - // 2. Project commands (override user commands) - dirs.push({ path: storage.getProjectCommandsDir() }); + // 2. Project commands + dirs.push({ + path: storage.getProjectCommandsDir(), + kind: CommandKind.WORKSPACE_FILE, + }); // 3. Extension commands (processed last to detect all conflicts) if (this.config) { @@ -165,6 +172,7 @@ export class FileCommandLoader implements ICommandLoader { const extensionCommandDirs = activeExtensions.map((ext) => ({ path: path.join(ext.path, 'commands'), + kind: CommandKind.EXTENSION_FILE, extensionName: ext.name, extensionId: ext.id, })); @@ -179,12 +187,14 @@ export class FileCommandLoader implements ICommandLoader { * Parses a single .toml file and transforms it into a SlashCommand object. * @param filePath The absolute path to the .toml file. * @param baseDir The root command directory for name calculation. + * @param kind The CommandKind. * @param extensionName Optional extension name to prefix commands with. * @returns A promise resolving to a SlashCommand, or null if the file is invalid. */ private async parseAndAdaptFile( filePath: string, baseDir: string, + kind: CommandKind, extensionName?: string, extensionId?: string, ): Promise { @@ -286,7 +296,7 @@ export class FileCommandLoader implements ICommandLoader { return { name: baseCommandName, description, - kind: CommandKind.FILE, + kind, extensionName, extensionId, action: async ( diff --git a/packages/cli/src/services/McpPromptLoader.ts b/packages/cli/src/services/McpPromptLoader.ts index f61eed9184..5be2ad846d 100644 --- a/packages/cli/src/services/McpPromptLoader.ts +++ b/packages/cli/src/services/McpPromptLoader.ts @@ -4,14 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '@google/gemini-cli-core'; -import { getErrorMessage, getMCPServerPrompts } from '@google/gemini-cli-core'; -import type { - CommandContext, - SlashCommand, - SlashCommandActionReturn, +import { + getErrorMessage, + getMCPServerPrompts, + type Config, +} from '@google/gemini-cli-core'; +import { + CommandKind, + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, } from '../ui/commands/types.js'; -import { CommandKind } from '../ui/commands/types.js'; import type { ICommandLoader } from './types.js'; import type { PromptArgument } from '@modelcontextprotocol/sdk/types.js'; @@ -44,6 +47,7 @@ export class McpPromptLoader implements ICommandLoader { name: commandName, description: prompt.description || `Invoke prompt ${prompt.name}`, kind: CommandKind.MCP_PROMPT, + mcpServerName: serverName, autoExecute: !prompt.arguments || prompt.arguments.length === 0, subCommands: [ { diff --git a/packages/cli/src/services/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts new file mode 100644 index 0000000000..51cc098536 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { SkillCommandLoader } from './SkillCommandLoader.js'; +import { CommandKind } from '../ui/commands/types.js'; +import { ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core'; + +describe('SkillCommandLoader', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockConfig: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockSkillManager: any; + + beforeEach(() => { + mockSkillManager = { + getDisplayableSkills: vi.fn(), + isAdminEnabled: vi.fn().mockReturnValue(true), + }; + + mockConfig = { + isSkillsSupportEnabled: vi.fn().mockReturnValue(true), + getSkillManager: vi.fn().mockReturnValue(mockSkillManager), + }; + }); + + it('should return an empty array if skills support is disabled', async () => { + mockConfig.isSkillsSupportEnabled.mockReturnValue(false); + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + expect(commands).toEqual([]); + }); + + it('should return an empty array if SkillManager is missing', async () => { + mockConfig.getSkillManager.mockReturnValue(null); + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + expect(commands).toEqual([]); + }); + + it('should return an empty array if skills are admin-disabled', async () => { + mockSkillManager.isAdminEnabled.mockReturnValue(false); + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + expect(commands).toEqual([]); + }); + + it('should load skills as slash commands', async () => { + const mockSkills = [ + { name: 'skill1', description: 'Description 1' }, + { name: 'skill2', description: '' }, + ]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + expect(commands).toHaveLength(2); + + expect(commands[0]).toMatchObject({ + name: 'skill1', + description: 'Description 1', + kind: CommandKind.SKILL, + autoExecute: true, + }); + + expect(commands[1]).toMatchObject({ + name: 'skill2', + description: 'Activate the skill2 skill', + kind: CommandKind.SKILL, + autoExecute: true, + }); + }); + + it('should return a tool action when a skill command is executed', async () => { + const mockSkills = [{ name: 'test-skill', description: 'Test skill' }]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actionResult = await commands[0].action!({} as any, ''); + expect(actionResult).toEqual({ + type: 'tool', + toolName: ACTIVATE_SKILL_TOOL_NAME, + toolArgs: { name: 'test-skill' }, + postSubmitPrompt: undefined, + }); + }); + + it('should return a tool action with postSubmitPrompt when args are provided', async () => { + const mockSkills = [{ name: 'test-skill', description: 'Test skill' }]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actionResult = await commands[0].action!({} as any, 'hello world'); + expect(actionResult).toEqual({ + type: 'tool', + toolName: ACTIVATE_SKILL_TOOL_NAME, + toolArgs: { name: 'test-skill' }, + postSubmitPrompt: 'hello world', + }); + }); + + it('should sanitize skill names with spaces', async () => { + const mockSkills = [{ name: 'my awesome skill', description: 'Desc' }]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + expect(commands[0].name).toBe('my-awesome-skill'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actionResult = (await commands[0].action!({} as any, '')) as any; + expect(actionResult.toolArgs).toEqual({ name: 'my awesome skill' }); + }); + + it('should propagate extensionName to the generated slash command', async () => { + const mockSkills = [ + { name: 'skill1', description: 'desc', extensionName: 'ext1' }, + ]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + expect(commands[0].extensionName).toBe('ext1'); + }); +}); diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts new file mode 100644 index 0000000000..e264da2e31 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type Config, ACTIVATE_SKILL_TOOL_NAME } from '@google/gemini-cli-core'; +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; +import { type ICommandLoader } from './types.js'; + +/** + * Loads Agent Skills as slash commands. + */ +export class SkillCommandLoader implements ICommandLoader { + constructor(private config: Config | null) {} + + /** + * Discovers all available skills from the SkillManager and converts + * them into executable slash commands. + * + * @param _signal An AbortSignal (unused for this synchronous loader). + * @returns A promise that resolves to an array of `SlashCommand` objects. + */ + async loadCommands(_signal: AbortSignal): Promise { + if (!this.config || !this.config.isSkillsSupportEnabled()) { + return []; + } + + const skillManager = this.config.getSkillManager(); + if (!skillManager || !skillManager.isAdminEnabled()) { + return []; + } + + // Convert all displayable skills into slash commands. + const skills = skillManager.getDisplayableSkills(); + + return skills.map((skill) => { + const commandName = skill.name.trim().replace(/\s+/g, '-'); + return { + name: commandName, + description: skill.description || `Activate the ${skill.name} skill`, + kind: CommandKind.SKILL, + autoExecute: true, + extensionName: skill.extensionName, + action: async (_context, args) => ({ + type: 'tool', + toolName: ACTIVATE_SKILL_TOOL_NAME, + toolArgs: { name: skill.name }, + postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined, + }), + }; + }); + } +} diff --git a/packages/cli/src/services/SlashCommandConflictHandler.test.ts b/packages/cli/src/services/SlashCommandConflictHandler.test.ts new file mode 100644 index 0000000000..5527188a04 --- /dev/null +++ b/packages/cli/src/services/SlashCommandConflictHandler.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SlashCommandConflictHandler } from './SlashCommandConflictHandler.js'; +import { + coreEvents, + CoreEvent, + type SlashCommandConflictsPayload, + type SlashCommandConflict, +} from '@google/gemini-cli-core'; +import { CommandKind } from '../ui/commands/types.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + coreEvents: { + on: vi.fn(), + off: vi.fn(), + emitFeedback: vi.fn(), + }, + }; +}); + +describe('SlashCommandConflictHandler', () => { + let handler: SlashCommandConflictHandler; + + /** + * Helper to find and invoke the registered conflict event listener. + */ + const simulateEvent = (conflicts: SlashCommandConflict[]) => { + const callback = vi + .mocked(coreEvents.on) + .mock.calls.find( + (call) => call[0] === CoreEvent.SlashCommandConflicts, + )![1] as (payload: SlashCommandConflictsPayload) => void; + callback({ conflicts }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + handler = new SlashCommandConflictHandler(); + handler.start(); + }); + + afterEach(() => { + handler.stop(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should listen for conflict events on start', () => { + expect(coreEvents.on).toHaveBeenCalledWith( + CoreEvent.SlashCommandConflicts, + expect.any(Function), + ); + }); + + it('should display a descriptive message for a single extension conflict', () => { + simulateEvent([ + { + name: 'deploy', + renamedTo: 'firebase.deploy', + loserExtensionName: 'firebase', + loserKind: CommandKind.EXTENSION_FILE, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + "Extension 'firebase' command '/deploy' was renamed to '/firebase.deploy' because it conflicts with built-in command.", + ); + }); + + it('should display a descriptive message for a single MCP conflict', () => { + simulateEvent([ + { + name: 'pickle', + renamedTo: 'test-server.pickle', + loserMcpServerName: 'test-server', + loserKind: CommandKind.MCP_PROMPT, + winnerExtensionName: 'pickle-rick', + winnerKind: CommandKind.EXTENSION_FILE, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + "MCP server 'test-server' command '/pickle' was renamed to '/test-server.pickle' because it conflicts with extension 'pickle-rick' command.", + ); + }); + + it('should group multiple conflicts for the same command name', () => { + simulateEvent([ + { + name: 'launch', + renamedTo: 'user.launch', + loserKind: CommandKind.USER_FILE, + winnerKind: CommandKind.WORKSPACE_FILE, + }, + { + name: 'launch', + renamedTo: 'workspace.launch', + loserKind: CommandKind.WORKSPACE_FILE, + winnerKind: CommandKind.USER_FILE, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + `Conflicts detected for command '/launch': +- User command '/launch' was renamed to '/user.launch' +- Workspace command '/launch' was renamed to '/workspace.launch'`, + ); + }); + + it('should debounce multiple events within the flush window', () => { + simulateEvent([ + { + name: 'a', + renamedTo: 'user.a', + loserKind: CommandKind.USER_FILE, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(200); + + simulateEvent([ + { + name: 'b', + renamedTo: 'user.b', + loserKind: CommandKind.USER_FILE, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(600); + + // Should emit two feedbacks (one for each unique command name) + expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(2); + }); + + it('should deduplicate already notified conflicts', () => { + const conflict = { + name: 'deploy', + renamedTo: 'firebase.deploy', + loserExtensionName: 'firebase', + loserKind: CommandKind.EXTENSION_FILE, + winnerKind: CommandKind.BUILT_IN, + }; + + simulateEvent([conflict]); + vi.advanceTimersByTime(600); + expect(coreEvents.emitFeedback).toHaveBeenCalledTimes(1); + + vi.mocked(coreEvents.emitFeedback).mockClear(); + + simulateEvent([conflict]); + vi.advanceTimersByTime(600); + expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); + }); + + it('should display a descriptive message for a skill conflict', () => { + simulateEvent([ + { + name: 'chat', + renamedTo: 'google-workspace.chat', + loserExtensionName: 'google-workspace', + loserKind: CommandKind.SKILL, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + "Extension 'google-workspace' skill '/chat' was renamed to '/google-workspace.chat' because it conflicts with built-in command.", + ); + }); +}); diff --git a/packages/cli/src/services/SlashCommandConflictHandler.ts b/packages/cli/src/services/SlashCommandConflictHandler.ts index 31e110732b..7da4e53842 100644 --- a/packages/cli/src/services/SlashCommandConflictHandler.ts +++ b/packages/cli/src/services/SlashCommandConflictHandler.ts @@ -8,10 +8,20 @@ import { coreEvents, CoreEvent, type SlashCommandConflictsPayload, + type SlashCommandConflict, } from '@google/gemini-cli-core'; +import { CommandKind } from '../ui/commands/types.js'; +/** + * Handles slash command conflict events and provides user feedback. + * + * This handler batches multiple conflict events into a single notification + * block per command name to avoid UI clutter during startup or incremental loading. + */ export class SlashCommandConflictHandler { private notifiedConflicts = new Set(); + private pendingConflicts: SlashCommandConflict[] = []; + private flushTimeout: ReturnType | null = null; constructor() { this.handleConflicts = this.handleConflicts.bind(this); @@ -23,11 +33,18 @@ export class SlashCommandConflictHandler { stop() { coreEvents.off(CoreEvent.SlashCommandConflicts, this.handleConflicts); + if (this.flushTimeout) { + clearTimeout(this.flushTimeout); + this.flushTimeout = null; + } } private handleConflicts(payload: SlashCommandConflictsPayload) { const newConflicts = payload.conflicts.filter((c) => { - const key = `${c.name}:${c.loserExtensionName}`; + // Use a unique key to prevent duplicate notifications for the same conflict + const sourceId = + c.loserExtensionName || c.loserMcpServerName || c.loserKind; + const key = `${c.name}:${sourceId}:${c.renamedTo}`; if (this.notifiedConflicts.has(key)) { return false; } @@ -36,19 +53,123 @@ export class SlashCommandConflictHandler { }); if (newConflicts.length > 0) { - const conflictMessages = newConflicts - .map((c) => { - const winnerSource = c.winnerExtensionName - ? `extension '${c.winnerExtensionName}'` - : 'an existing command'; - return `- Command '/${c.name}' from extension '${c.loserExtensionName}' was renamed to '/${c.renamedTo}' because it conflicts with ${winnerSource}.`; - }) - .join('\n'); + this.pendingConflicts.push(...newConflicts); + this.scheduleFlush(); + } + } - coreEvents.emitFeedback( - 'info', - `Command conflicts detected:\n${conflictMessages}`, - ); + private scheduleFlush() { + if (this.flushTimeout) { + clearTimeout(this.flushTimeout); + } + // Use a trailing debounce to capture staggered reloads during startup + this.flushTimeout = setTimeout(() => this.flush(), 500); + } + + private flush() { + this.flushTimeout = null; + const conflicts = [...this.pendingConflicts]; + this.pendingConflicts = []; + + if (conflicts.length === 0) { + return; + } + + // Group conflicts by their original command name + const grouped = new Map(); + for (const c of conflicts) { + const list = grouped.get(c.name) ?? []; + list.push(c); + grouped.set(c.name, list); + } + + for (const [name, commandConflicts] of grouped) { + if (commandConflicts.length > 1) { + this.emitGroupedFeedback(name, commandConflicts); + } else { + this.emitSingleFeedback(commandConflicts[0]); + } + } + } + + /** + * Emits a grouped notification for multiple conflicts sharing the same name. + */ + private emitGroupedFeedback( + name: string, + conflicts: SlashCommandConflict[], + ): void { + const messages = conflicts + .map((c) => { + const source = this.getSourceDescription( + c.loserExtensionName, + c.loserKind, + c.loserMcpServerName, + ); + return `- ${this.capitalize(source)} '/${c.name}' was renamed to '/${c.renamedTo}'`; + }) + .join('\n'); + + coreEvents.emitFeedback( + 'info', + `Conflicts detected for command '/${name}':\n${messages}`, + ); + } + + /** + * Emits a descriptive notification for a single command conflict. + */ + private emitSingleFeedback(c: SlashCommandConflict): void { + const loserSource = this.getSourceDescription( + c.loserExtensionName, + c.loserKind, + c.loserMcpServerName, + ); + const winnerSource = this.getSourceDescription( + c.winnerExtensionName, + c.winnerKind, + c.winnerMcpServerName, + ); + + coreEvents.emitFeedback( + 'info', + `${this.capitalize(loserSource)} '/${c.name}' was renamed to '/${c.renamedTo}' because it conflicts with ${winnerSource}.`, + ); + } + + private capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); + } + + /** + * Returns a human-readable description of a command's source. + */ + private getSourceDescription( + extensionName?: string, + kind?: string, + mcpServerName?: string, + ): string { + switch (kind) { + case CommandKind.EXTENSION_FILE: + return extensionName + ? `extension '${extensionName}' command` + : 'extension command'; + case CommandKind.SKILL: + return extensionName + ? `extension '${extensionName}' skill` + : 'skill command'; + case CommandKind.MCP_PROMPT: + return mcpServerName + ? `MCP server '${mcpServerName}' command` + : 'MCP server command'; + case CommandKind.USER_FILE: + return 'user command'; + case CommandKind.WORKSPACE_FILE: + return 'workspace command'; + case CommandKind.BUILT_IN: + return 'built-in command'; + default: + return 'existing command'; } } } diff --git a/packages/cli/src/services/SlashCommandResolver.test.ts b/packages/cli/src/services/SlashCommandResolver.test.ts new file mode 100644 index 0000000000..43d1c310a8 --- /dev/null +++ b/packages/cli/src/services/SlashCommandResolver.test.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SlashCommandResolver } from './SlashCommandResolver.js'; +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; + +const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ + name, + description: `Description for ${name}`, + kind, + action: vi.fn(), +}); + +describe('SlashCommandResolver', () => { + describe('resolve', () => { + it('should return all commands when there are no conflicts', () => { + const cmdA = createMockCommand('a', CommandKind.BUILT_IN); + const cmdB = createMockCommand('b', CommandKind.USER_FILE); + + const { finalCommands, conflicts } = SlashCommandResolver.resolve([ + cmdA, + cmdB, + ]); + + expect(finalCommands).toHaveLength(2); + expect(conflicts).toHaveLength(0); + }); + + it('should rename extension commands when they conflict with built-in', () => { + const builtin = createMockCommand('deploy', CommandKind.BUILT_IN); + const extension = { + ...createMockCommand('deploy', CommandKind.EXTENSION_FILE), + extensionName: 'firebase', + }; + + const { finalCommands, conflicts } = SlashCommandResolver.resolve([ + builtin, + extension, + ]); + + expect(finalCommands.map((c) => c.name)).toContain('deploy'); + expect(finalCommands.map((c) => c.name)).toContain('firebase.deploy'); + expect(conflicts).toHaveLength(1); + }); + + it('should prefix both user and workspace commands when they conflict', () => { + const userCmd = createMockCommand('sync', CommandKind.USER_FILE); + const workspaceCmd = createMockCommand( + 'sync', + CommandKind.WORKSPACE_FILE, + ); + + const { finalCommands, conflicts } = SlashCommandResolver.resolve([ + userCmd, + workspaceCmd, + ]); + + const names = finalCommands.map((c) => c.name); + expect(names).not.toContain('sync'); + expect(names).toContain('user.sync'); + expect(names).toContain('workspace.sync'); + expect(conflicts).toHaveLength(1); + expect(conflicts[0].losers).toHaveLength(2); // Both are considered losers + }); + + it('should prefix file commands but keep built-in names during conflicts', () => { + const builtin = createMockCommand('help', CommandKind.BUILT_IN); + const user = createMockCommand('help', CommandKind.USER_FILE); + + const { finalCommands } = SlashCommandResolver.resolve([builtin, user]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('help'); + expect(names).toContain('user.help'); + }); + + it('should prefix both commands when MCP and user file conflict', () => { + const mcp = { + ...createMockCommand('test', CommandKind.MCP_PROMPT), + mcpServerName: 'test-server', + }; + const user = createMockCommand('test', CommandKind.USER_FILE); + + const { finalCommands } = SlashCommandResolver.resolve([mcp, user]); + + const names = finalCommands.map((c) => c.name); + expect(names).not.toContain('test'); + expect(names).toContain('test-server.test'); + expect(names).toContain('user.test'); + }); + + it('should prefix MCP commands with server name when they conflict with built-in', () => { + const builtin = createMockCommand('help', CommandKind.BUILT_IN); + const mcp = { + ...createMockCommand('help', CommandKind.MCP_PROMPT), + mcpServerName: 'test-server', + }; + + const { finalCommands } = SlashCommandResolver.resolve([builtin, mcp]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('help'); + expect(names).toContain('test-server.help'); + }); + + it('should prefix both MCP commands when they conflict with each other', () => { + const mcp1 = { + ...createMockCommand('test', CommandKind.MCP_PROMPT), + mcpServerName: 'server1', + }; + const mcp2 = { + ...createMockCommand('test', CommandKind.MCP_PROMPT), + mcpServerName: 'server2', + }; + + const { finalCommands } = SlashCommandResolver.resolve([mcp1, mcp2]); + + const names = finalCommands.map((c) => c.name); + expect(names).not.toContain('test'); + expect(names).toContain('server1.test'); + expect(names).toContain('server2.test'); + }); + + it('should favor the last built-in command silently during conflicts', () => { + const builtin1 = { + ...createMockCommand('help', CommandKind.BUILT_IN), + description: 'first', + }; + const builtin2 = { + ...createMockCommand('help', CommandKind.BUILT_IN), + description: 'second', + }; + + const { finalCommands } = SlashCommandResolver.resolve([ + builtin1, + builtin2, + ]); + + expect(finalCommands).toHaveLength(1); + expect(finalCommands[0].description).toBe('second'); + }); + + it('should fallback to numeric suffixes when both prefix and kind-based prefix are missing', () => { + const cmd1 = createMockCommand('test', CommandKind.BUILT_IN); + const cmd2 = { + ...createMockCommand('test', 'unknown' as CommandKind), + }; + + const { finalCommands } = SlashCommandResolver.resolve([cmd1, cmd2]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('test'); + expect(names).toContain('test1'); + }); + + it('should apply numeric suffixes when renames also conflict', () => { + const user1 = createMockCommand('deploy', CommandKind.USER_FILE); + const user2 = createMockCommand('gcp.deploy', CommandKind.USER_FILE); + const extension = { + ...createMockCommand('deploy', CommandKind.EXTENSION_FILE), + extensionName: 'gcp', + }; + + const { finalCommands } = SlashCommandResolver.resolve([ + user1, + user2, + extension, + ]); + + expect(finalCommands.find((c) => c.name === 'gcp.deploy1')).toBeDefined(); + }); + + it('should prefix skills with extension name when they conflict with built-in', () => { + const builtin = createMockCommand('chat', CommandKind.BUILT_IN); + const skill = { + ...createMockCommand('chat', CommandKind.SKILL), + extensionName: 'google-workspace', + }; + + const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('chat'); + expect(names).toContain('google-workspace.chat'); + }); + + it('should NOT prefix skills with "skill" when extension name is missing', () => { + const builtin = createMockCommand('chat', CommandKind.BUILT_IN); + const skill = createMockCommand('chat', CommandKind.SKILL); + + const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('chat'); + expect(names).toContain('chat1'); + }); + }); +}); diff --git a/packages/cli/src/services/SlashCommandResolver.ts b/packages/cli/src/services/SlashCommandResolver.ts new file mode 100644 index 0000000000..4947e6545a --- /dev/null +++ b/packages/cli/src/services/SlashCommandResolver.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; +import type { CommandConflict } from './types.js'; + +/** + * Internal registry to track commands and conflicts during resolution. + */ +class CommandRegistry { + readonly commandMap = new Map(); + readonly conflictsMap = new Map(); + readonly firstEncounters = new Map(); + + get finalCommands(): SlashCommand[] { + return Array.from(this.commandMap.values()); + } + + get conflicts(): CommandConflict[] { + return Array.from(this.conflictsMap.values()); + } +} + +/** + * Resolves name conflicts among slash commands. + * + * Rules: + * 1. Built-in commands always keep the original name. + * 2. All other types are prefixed with their source name (e.g. user.name). + * 3. If multiple non-built-in commands conflict, all of them are renamed. + */ +export class SlashCommandResolver { + /** + * Orchestrates conflict resolution by applying renaming rules to ensures + * every command has a unique name. + */ + static resolve(allCommands: SlashCommand[]): { + finalCommands: SlashCommand[]; + conflicts: CommandConflict[]; + } { + const registry = new CommandRegistry(); + + for (const cmd of allCommands) { + const originalName = cmd.name; + let finalName = originalName; + + if (registry.firstEncounters.has(originalName)) { + // We've already seen a command with this name, so resolve the conflict. + finalName = this.handleConflict(cmd, registry); + } else { + // Track the first claimant to report them as the conflict reason later. + registry.firstEncounters.set(originalName, cmd); + } + + // Store under final name, ensuring the command object reflects it. + registry.commandMap.set(finalName, { + ...cmd, + name: finalName, + }); + } + + return { + finalCommands: registry.finalCommands, + conflicts: registry.conflicts, + }; + } + + /** + * Resolves a name collision by deciding which command keeps the name and which is renamed. + * + * @param incoming The command currently being processed that has a name collision. + * @param registry The internal state of the resolution process. + * @returns The final name to be assigned to the `incoming` command. + */ + private static handleConflict( + incoming: SlashCommand, + registry: CommandRegistry, + ): string { + const collidingName = incoming.name; + const originalClaimant = registry.firstEncounters.get(collidingName)!; + + // Incoming built-in takes priority. Prefix any existing owner. + if (incoming.kind === CommandKind.BUILT_IN) { + this.prefixExistingCommand(collidingName, incoming, registry); + return collidingName; + } + + // Incoming non-built-in is renamed to its source-prefixed version. + const renamedName = this.getRenamedName( + incoming.name, + this.getPrefix(incoming), + registry.commandMap, + ); + this.trackConflict( + registry.conflictsMap, + collidingName, + originalClaimant, + incoming, + renamedName, + ); + + // Prefix current owner as well if it isn't a built-in. + this.prefixExistingCommand(collidingName, incoming, registry); + + return renamedName; + } + + /** + * Safely renames the command currently occupying a name in the registry. + * + * @param name The name of the command to prefix. + * @param reason The incoming command that is causing the prefixing. + * @param registry The internal state of the resolution process. + */ + private static prefixExistingCommand( + name: string, + reason: SlashCommand, + registry: CommandRegistry, + ): void { + const currentOwner = registry.commandMap.get(name); + + // Only non-built-in commands can be prefixed. + if (!currentOwner || currentOwner.kind === CommandKind.BUILT_IN) { + return; + } + + // Determine the new name for the owner using its source prefix. + const renamedName = this.getRenamedName( + currentOwner.name, + this.getPrefix(currentOwner), + registry.commandMap, + ); + + // Update the registry: remove the old name and add the owner under the new name. + registry.commandMap.delete(name); + const renamedOwner = { ...currentOwner, name: renamedName }; + registry.commandMap.set(renamedName, renamedOwner); + + // Record the conflict so the user can be notified of the prefixing. + this.trackConflict( + registry.conflictsMap, + name, + reason, + currentOwner, + renamedName, + ); + } + + /** + * Generates a unique name using numeric suffixes if needed. + */ + private static getRenamedName( + name: string, + prefix: string | undefined, + commandMap: Map, + ): string { + const base = prefix ? `${prefix}.${name}` : name; + let renamedName = base; + let suffix = 1; + + while (commandMap.has(renamedName)) { + renamedName = `${base}${suffix}`; + suffix++; + } + return renamedName; + } + + /** + * Returns a suitable prefix for a conflicting command. + */ + private static getPrefix(cmd: SlashCommand): string | undefined { + switch (cmd.kind) { + case CommandKind.EXTENSION_FILE: + case CommandKind.SKILL: + return cmd.extensionName; + case CommandKind.MCP_PROMPT: + return cmd.mcpServerName; + case CommandKind.USER_FILE: + return 'user'; + case CommandKind.WORKSPACE_FILE: + return 'workspace'; + default: + return undefined; + } + } + /** + * Logs a conflict event. + */ + private static trackConflict( + conflictsMap: Map, + originalName: string, + reason: SlashCommand, + displacedCommand: SlashCommand, + renamedTo: string, + ) { + if (!conflictsMap.has(originalName)) { + conflictsMap.set(originalName, { + name: originalName, + losers: [], + }); + } + + conflictsMap.get(originalName)!.losers.push({ + command: displacedCommand, + renamedTo, + reason, + }); + } +} diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts index 3f49248169..3b84baae67 100644 --- a/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts @@ -31,11 +31,14 @@ describe('AtFileProcessor', () => { mockConfig = { // The processor only passes the config through, so we don't need a full mock. + get config() { + return this; + }, } as unknown as Config; context = createMockCommandContext({ services: { - config: mockConfig, + agentContext: mockConfig, }, }); @@ -60,7 +63,7 @@ describe('AtFileProcessor', () => { const prompt: PartUnion[] = [{ text: 'Analyze @{file.txt}' }]; const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); const result = await processor.process(prompt, contextWithoutConfig); diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.ts index 48e527ed5f..8c1b168584 100644 --- a/packages/cli/src/services/prompt-processors/atFileProcessor.ts +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.ts @@ -25,7 +25,7 @@ export class AtFileProcessor implements IPromptProcessor { input: PromptPipelineContent, context: CommandContext, ): Promise { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { return input; } diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 0f6fb562a8..8ab4581228 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -13,6 +13,7 @@ import { ApprovalMode, getShellConfiguration, PolicyDecision, + NoopSandboxManager, } from '@google/gemini-cli-core'; import { quote } from 'shell-quote'; import { createPartFromText } from '@google/genai'; @@ -77,10 +78,20 @@ describe('ShellProcessor', () => { getTargetDir: vi.fn().mockReturnValue('/test/dir'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getEnableInteractiveShell: vi.fn().mockReturnValue(false), - getShellExecutionConfig: vi.fn().mockReturnValue({}), + getShellExecutionConfig: vi.fn().mockReturnValue({ + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), getPolicyEngine: vi.fn().mockReturnValue({ check: mockPolicyEngineCheck, }), + get config() { + return this as unknown as Config; + }, }; context = createMockCommandContext({ @@ -90,7 +101,7 @@ describe('ShellProcessor', () => { args: 'default args', }, services: { - config: mockConfig as Config, + agentContext: mockConfig as Config, }, session: { sessionShellAllowlist: new Set(), @@ -112,7 +123,7 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent('!{ls}'); const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 4c8369f664..0042dc4f49 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -74,7 +74,7 @@ export class ShellProcessor implements IPromptProcessor { ]; } - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { throw new Error( `Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`, diff --git a/packages/cli/src/services/types.ts b/packages/cli/src/services/types.ts index 13a87687ee..b583e56e39 100644 --- a/packages/cli/src/services/types.ts +++ b/packages/cli/src/services/types.ts @@ -22,3 +22,12 @@ export interface ICommandLoader { */ loadCommands(signal: AbortSignal): Promise; } + +export interface CommandConflict { + name: string; + losers: Array<{ + command: SlashCommand; + renamedTo: string; + reason: SlashCommand; + }>; +} diff --git a/packages/cli/src/test-utils/AppRig.test.tsx b/packages/cli/src/test-utils/AppRig.test.tsx index 76c0ddc522..6d94342937 100644 --- a/packages/cli/src/test-utils/AppRig.test.tsx +++ b/packages/cli/src/test-utils/AppRig.test.tsx @@ -5,7 +5,6 @@ */ import { describe, it, afterEach, expect } from 'vitest'; -import { act } from 'react'; import { AppRig } from './AppRig.js'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -31,7 +30,7 @@ describe('AppRig', () => { configOverrides: { modelSteering: true }, }); await rig.initialize(); - rig.render(); + await rig.render(); await rig.waitForIdle(); // Set breakpoints on the canonical tool names @@ -69,12 +68,7 @@ describe('AppRig', () => { ); rig = new AppRig({ fakeResponsesPath }); await rig.initialize(); - await act(async () => { - rig!.render(); - // Allow async initializations (like banners) to settle within the act boundary - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - + await rig.render(); // Wait for initial render await rig.waitForIdle(); diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index d953be0ff6..dbbe84ca90 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -11,7 +11,7 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { AppContainer } from '../ui/AppContainer.js'; -import { renderWithProviders } from './render.js'; +import { renderWithProviders, type RenderInstance } from './render.js'; import { makeFakeConfig, type Config, @@ -30,13 +30,17 @@ import { IdeClient, debugLogger, CoreToolCallStatus, + IntegrityDataStatus, } from '@google/gemini-cli-core'; import { type MockShellCommand, MockShellExecutionService, } from './MockShellExecutionService.js'; import { createMockSettings } from './settings.js'; -import { type LoadedSettings } from '../config/settings.js'; +import { + type LoadedSettings, + resetSettingsCacheForTesting, +} from '../config/settings.js'; import { AuthState, StreamingState } from '../ui/types.js'; import { randomUUID } from 'node:crypto'; import type { @@ -91,6 +95,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...original, + createCache: + original.createCache ?? + ((() => { + const cache = new Map(); + return { + clear: () => cache.clear(), + getOrCreate: (key: K, factory: () => V) => { + if (!cache.has(key)) { + cache.set(key, factory()); + } + return cache.get(key)!; + }, + }; + }) as typeof original.createCache), ShellExecutionService: MockService, }; }); @@ -104,6 +122,8 @@ vi.mock('../ui/auth/useAuth.js', () => ({ onAuthError: vi.fn(), apiKeyDefaultValue: 'test-api-key', reloadApiKey: vi.fn().mockResolvedValue('test-api-key'), + accountSuspensionInfo: null, + setAccountSuspensionInfo: vi.fn(), }), validateAuthMethodWithSettings: () => null, })); @@ -113,6 +133,12 @@ class MockExtensionManager extends ExtensionLoader { getExtensions = vi.fn().mockReturnValue([]); setRequestConsent = vi.fn(); setRequestSetting = vi.fn(); + integrityManager = { + verifyExtensionIntegrity: vi + .fn() + .mockResolvedValue(IntegrityDataStatus.VERIFIED), + storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined), + }; } // Mock GeminiRespondingSpinner to disable animations (avoiding 'act()' warnings) without triggering screen reader mode. @@ -143,7 +169,7 @@ export interface PendingConfirmation { } export class AppRig { - private renderResult: ReturnType | undefined; + private renderResult: RenderInstance | undefined; private config: Config | undefined; private settings: LoadedSettings | undefined; private testDir: string; @@ -169,6 +195,7 @@ export class AppRig { async initialize() { this.setupEnvironment(); + resetSettingsCacheForTesting(); this.settings = this.createRigSettings(); const approvalMode = @@ -191,6 +218,7 @@ export class AppRig { enableEventDrivenScheduler: true, extensionLoader: new MockExtensionManager(), excludeTools: this.options.configOverrides?.excludeTools, + useAlternateBuffer: false, ...this.options.configOverrides, }; this.config = makeFakeConfig(configParams); @@ -262,19 +290,22 @@ export class AppRig { enabled: false, hasSeenNudge: true, }, + ui: { + useAlternateBuffer: false, + }, }, }); } private stubRefreshAuth() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + // eslint-disable-next-line @typescript-eslint/no-explicit-any const gcConfig = this.config as any; gcConfig.refreshAuth = async (authMethod: AuthType) => { gcConfig.modelAvailabilityService.reset(); const newContentGeneratorConfig = { authType: authMethod, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + proxy: gcConfig.getProxy(), apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key', }; @@ -376,17 +407,18 @@ export class AppRig { return isAnyToolActive || isAwaitingConfirmation; } - render() { + async render() { if (!this.config || !this.settings) throw new Error('AppRig not initialized'); - act(() => { - this.renderResult = renderWithProviders( + await act(async () => { + this.renderResult = await renderWithProviders( { const matches = (p: PendingConfirmation) => { @@ -491,6 +522,12 @@ export class AppRig { p.toolDisplayName === toolNameOrDisplayName ); } + if (Array.isArray(toolNameOrDisplayName)) { + return ( + toolNameOrDisplayName.includes(p.toolName) || + toolNameOrDisplayName.includes(p.toolDisplayName || '') + ); + } return ( toolNameOrDisplayName.test(p.toolName) || toolNameOrDisplayName.test(p.toolDisplayName || '') @@ -604,7 +641,7 @@ export class AppRig { async addUserHint(hint: string) { if (!this.config) throw new Error('AppRig not initialized'); await act(async () => { - this.config!.userHintService.addUserHint(hint); + this.config!.injectionService.addInjection(hint, 'user_steering'); }); } @@ -709,7 +746,7 @@ export class AppRig { .getGeminiClient() ?.getChatRecordingService(); if (recordingService) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any (recordingService as any).conversationFile = null; } } @@ -729,7 +766,7 @@ export class AppRig { MockShellExecutionService.reset(); ideContextStore.clear(); // Forcefully clear IdeClient singleton promise - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any (IdeClient as any).instancePromise = null; vi.clearAllMocks(); diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts index ae9b44ee44..d34576cf3f 100644 --- a/packages/cli/src/test-utils/customMatchers.ts +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -79,7 +79,7 @@ export async function toMatchSvgSnapshot( } function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + // eslint-disable-next-line @typescript-eslint/no-explicit-any const { isNot } = this as any; let pass = true; const invalidLines: Array<{ line: number; content: string }> = []; @@ -108,7 +108,6 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { }; } -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion expect.extend({ toHaveOnlyValidCharacters, toMatchSvgSnapshot, diff --git a/packages/cli/src/test-utils/fixtures/steering.responses b/packages/cli/src/test-utils/fixtures/steering.responses index 66407f819e..6d843010f1 100644 --- a/packages/cli/src/test-utils/fixtures/steering.responses +++ b/packages/cli/src/test-utils/fixtures/steering.responses @@ -1,4 +1,3 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"Starting a long task. First, I'll list the files."},{"functionCall":{"name":"list_directory","args":{"dir_path":"."}}}]},"finishReason":"STOP"}]}]} -{"method":"generateContent","response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ACK: I will focus on .txt files now."}]},"finishReason":"STOP"}]}} {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I see the files. Since you want me to focus on .txt files, I will read file1.txt."},{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}}]},"finishReason":"STOP"}]}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I have read file1.txt. Task complete."}]},"finishReason":"STOP"}]}]} diff --git a/packages/cli/src/test-utils/mockCommandContext.test.ts b/packages/cli/src/test-utils/mockCommandContext.test.ts index 310bf74864..605718e027 100644 --- a/packages/cli/src/test-utils/mockCommandContext.test.ts +++ b/packages/cli/src/test-utils/mockCommandContext.test.ts @@ -46,15 +46,19 @@ describe('createMockCommandContext', () => { const overrides = { services: { - config: mockConfig, + agentContext: { config: mockConfig }, }, }; const context = createMockCommandContext(overrides); - expect(context.services.config).toBeDefined(); - expect(context.services.config?.getModel()).toBe('gemini-pro'); - expect(context.services.config?.getProjectRoot()).toBe('/test/project'); + expect(context.services.agentContext).toBeDefined(); + expect(context.services.agentContext?.config?.getModel()).toBe( + 'gemini-pro', + ); + expect(context.services.agentContext?.config?.getProjectRoot()).toBe( + '/test/project', + ); // Verify a default property on the same nested object is still there expect(context.services.logger).toBeDefined(); diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 8dc5b9930a..6eda7f3109 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -6,8 +6,7 @@ import { vi } from 'vitest'; import type { CommandContext } from '../ui/commands/types.js'; -import type { LoadedSettings } from '../config/settings.js'; -import { mergeSettings } from '../config/settings.js'; +import { mergeSettings, type LoadedSettings } from '../config/settings.js'; import type { GitService } from '@google/gemini-cli-core'; import type { SessionStatsState } from '../ui/contexts/SessionContext.js'; @@ -37,15 +36,13 @@ export const createMockCommandContext = ( args: '', }, services: { - config: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + agentContext: null, settings: { merged: defaultMergedSettings, setValue: vi.fn(), forScope: vi.fn().mockReturnValue({ settings: {} }), } as unknown as LoadedSettings, git: undefined as GitService | undefined, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment logger: { log: vi.fn(), logMessage: vi.fn(), @@ -54,7 +51,6 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment ui: { addItem: vi.fn(), clear: vi.fn(), @@ -73,7 +69,6 @@ export const createMockCommandContext = ( } as any, session: { sessionShellAllowlist: new Set(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion stats: { sessionStartTime: new Date(), lastPromptTokenCount: 0, @@ -94,14 +89,11 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any const merge = (target: any, source: any): any => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const output = { ...target }; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const sourceValue = source[key]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const targetValue = output[key]; if ( @@ -109,11 +101,9 @@ export const createMockCommandContext = ( Object.prototype.toString.call(sourceValue) === '[object Object]' && Object.prototype.toString.call(targetValue) === '[object Object]' ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment output[key] = merge(targetValue, sourceValue); } else { // If not, we do a direct assignment. This preserves Date objects and others. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment output[key] = sourceValue; } } @@ -121,6 +111,5 @@ export const createMockCommandContext = ( return output; }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return merge(defaultMocks, overrides); }; diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index 81853a0d89..0f35c9341a 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -5,15 +5,18 @@ */ import { vi } from 'vitest'; +import { NoopSandboxManager } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; -import type { LoadedSettings, Settings } from '../config/settings.js'; -import { createTestMergedSettings } from '../config/settings.js'; +import { + createTestMergedSettings, + type LoadedSettings, + type Settings, +} from '../config/settings.js'; /** * Creates a mocked Config object with default values and allows overrides. */ export const createMockConfig = (overrides: Partial = {}): Config => - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ({ getSandbox: vi.fn(() => undefined), getQuestion: vi.fn(() => ''), @@ -41,8 +44,9 @@ export const createMockConfig = (overrides: Partial = {}): Config => getDeleteSession: vi.fn(() => undefined), setSessionId: vi.fn(), getSessionId: vi.fn().mockReturnValue('mock-session-id'), + getWorktreeSettings: vi.fn(() => undefined), getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })), - getExperimentalZedIntegration: vi.fn(() => false), + getAcpMode: vi.fn(() => false), isBrowserLaunchSuppressed: vi.fn(() => false), setRemoteAdminSettings: vi.fn(), isYoloModeDisabled: vi.fn(() => false), @@ -75,6 +79,8 @@ export const createMockConfig = (overrides: Partial = {}): Config => getFileService: vi.fn().mockReturnValue({}), getGitService: vi.fn().mockResolvedValue({}), getUserMemory: vi.fn().mockReturnValue(''), + getSystemInstructionMemory: vi.fn().mockReturnValue(''), + getSessionMemory: vi.fn().mockReturnValue(''), getGeminiMdFilePaths: vi.fn().mockReturnValue([]), getShowMemoryUsage: vi.fn().mockReturnValue(false), getAccessibility: vi.fn().mockReturnValue({}), @@ -123,6 +129,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''), getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''), isInteractiveShellEnabled: vi.fn().mockReturnValue(false), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), isSkillsSupportEnabled: vi.fn().mockReturnValue(false), reloadSkills: vi.fn().mockResolvedValue(undefined), reloadAgents: vi.fn().mockResolvedValue(undefined), @@ -130,10 +137,17 @@ export const createMockConfig = (overrides: Partial = {}): Config => getEnableInteractiveShell: vi.fn().mockReturnValue(false), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), getContinueOnFailedApiCall: vi.fn().mockReturnValue(false), - getRetryFetchErrors: vi.fn().mockReturnValue(false), + getRetryFetchErrors: vi.fn().mockReturnValue(true), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000), - getShellExecutionConfig: vi.fn().mockReturnValue({}), + getShellExecutionConfig: vi.fn().mockReturnValue({ + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), setShellExecutionConfig: vi.fn(), getEnableToolOutputTruncation: vi.fn().mockReturnValue(true), getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000), @@ -146,7 +160,10 @@ export const createMockConfig = (overrides: Partial = {}): Config => getMcpClientManager: vi.fn().mockReturnValue({ getMcpInstructions: vi.fn().mockReturnValue(''), getMcpServers: vi.fn().mockReturnValue({}), + getLastError: vi.fn().mockReturnValue(undefined), }), + setUserInteractedWithMcp: vi.fn(), + emitMcpDiagnostic: vi.fn(), getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false), getAdminSkillsEnabled: vi.fn().mockReturnValue(false), getDisabledSkills: vi.fn().mockReturnValue([]), @@ -161,6 +178,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getExperiments: vi.fn().mockReturnValue(undefined), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), validatePathAccess: vi.fn().mockReturnValue(null), + getUseAlternateBuffer: vi.fn().mockReturnValue(false), ...overrides, }) as unknown as Config; @@ -171,11 +189,9 @@ export function createMockSettings( overrides: Record = {}, ): LoadedSettings { const merged = createTestMergedSettings( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (overrides['merged'] as Partial) || {}, ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return { system: { settings: {} }, systemDefaults: { settings: {} }, diff --git a/packages/cli/src/test-utils/mockDebugLogger.ts b/packages/cli/src/test-utils/mockDebugLogger.ts index 02eb3b05d9..bc0cde9010 100644 --- a/packages/cli/src/test-utils/mockDebugLogger.ts +++ b/packages/cli/src/test-utils/mockDebugLogger.ts @@ -65,6 +65,7 @@ export function mockCoreDebugLogger>( return { ...actual, coreEvents: { + // eslint-disable-next-line no-restricted-syntax ...(typeof actual['coreEvents'] === 'object' && actual['coreEvents'] !== null ? actual['coreEvents'] diff --git a/packages/cli/src/test-utils/render.test.tsx b/packages/cli/src/test-utils/render.test.tsx index 7172a99119..3c3f4102a4 100644 --- a/packages/cli/src/test-utils/render.test.tsx +++ b/packages/cli/src/test-utils/render.test.tsx @@ -12,24 +12,18 @@ import { waitFor } from './async.js'; describe('render', () => { it('should render a component', async () => { - const { lastFrame, waitUntilReady, unmount } = render( - Hello World, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await render(Hello World); expect(lastFrame()).toBe('Hello World\n'); unmount(); }); it('should support rerender', async () => { - const { lastFrame, rerender, waitUntilReady, unmount } = render( + const { lastFrame, rerender, waitUntilReady, unmount } = await render( Hello, ); - await waitUntilReady(); expect(lastFrame()).toBe('Hello\n'); - await act(async () => { - rerender(World); - }); + await act(async () => rerender(World)); await waitUntilReady(); expect(lastFrame()).toBe('World\n'); unmount(); @@ -42,10 +36,8 @@ describe('render', () => { return Hello; } - const { unmount, waitUntilReady } = render(); - await waitUntilReady(); + const { unmount } = await render(); unmount(); - expect(cleanupMock).toHaveBeenCalled(); }); }); @@ -54,36 +46,27 @@ describe('renderHook', () => { it('should rerender with previous props when called without arguments', async () => { const useTestHook = ({ value }: { value: number }) => { const [count, setCount] = useState(0); - useEffect(() => { - setCount((c) => c + 1); - }, [value]); + useEffect(() => setCount((c) => c + 1), [value]); return { count, value }; }; - const { result, rerender, waitUntilReady, unmount } = renderHook( + const { result, rerender, waitUntilReady, unmount } = await renderHook( useTestHook, - { - initialProps: { value: 1 }, - }, + { initialProps: { value: 1 } }, ); - await waitUntilReady(); expect(result.current.value).toBe(1); await waitFor(() => expect(result.current.count).toBe(1)); // Rerender with new props - await act(async () => { - rerender({ value: 2 }); - }); + await act(async () => rerender({ value: 2 })); await waitUntilReady(); expect(result.current.value).toBe(2); await waitFor(() => expect(result.current.count).toBe(2)); // Rerender without arguments should use previous props (value: 2) // This would previously crash or pass undefined if not fixed - await act(async () => { - rerender(); - }); + await act(async () => rerender()); await waitUntilReady(); expect(result.current.value).toBe(2); // Count should not increase because value didn't change @@ -98,14 +81,11 @@ describe('renderHook', () => { }; const { result, rerender, waitUntilReady, unmount } = - renderHook(useTestHook); - await waitUntilReady(); + await renderHook(useTestHook); expect(result.current.count).toBe(0); - await act(async () => { - rerender(); - }); + await act(async () => rerender()); await waitUntilReady(); expect(result.current.count).toBe(0); unmount(); @@ -113,19 +93,14 @@ describe('renderHook', () => { it('should update props if undefined is passed explicitly', async () => { const useTestHook = (val: string | undefined) => val; - const { result, rerender, waitUntilReady, unmount } = renderHook( + const { result, rerender, waitUntilReady, unmount } = await renderHook( useTestHook, - { - initialProps: 'initial' as string | undefined, - }, + { initialProps: 'initial' }, ); - await waitUntilReady(); expect(result.current).toBe('initial'); - await act(async () => { - rerender(undefined); - }); + await act(async () => rerender(undefined)); await waitUntilReady(); expect(result.current).toBeUndefined(); unmount(); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 1b64c07d7b..04a642d687 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -11,13 +11,12 @@ import { } from 'ink'; import { EventEmitter } from 'node:events'; import { Box } from 'ink'; -import type React from 'react'; import { Terminal } from '@xterm/headless'; import { vi } from 'vitest'; import stripAnsi from 'strip-ansi'; +import type React from 'react'; import { act, useState } from 'react'; -import os from 'node:os'; -import { LoadedSettings } from '../config/settings.js'; +import type { LoadedSettings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; @@ -43,15 +42,16 @@ import { type OverflowState, } from '../ui/contexts/OverflowContext.js'; -import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; +import { type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; import { AppContext, type AppState } from '../ui/contexts/AppContext.js'; import { createMockSettings } from './settings.js'; import { SessionStatsProvider } from '../ui/contexts/SessionContext.js'; import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; -import { DefaultLight } from '../ui/themes/default-light.js'; +import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; import { pickDefaultThemeName } from '../ui/themes/theme.js'; import { generateSvgForTerminal } from './svg.js'; +import { loadCliConfig, type CliArgs } from '../config/config.js'; export const persistentStateMock = new FakePersistentState(); @@ -65,7 +65,9 @@ if (process.env['NODE_ENV'] === 'test') { } vi.mock('../utils/persistentState.js', () => ({ - persistentState: persistentStateMock, + get persistentState() { + return persistentStateMock; + }, })); vi.mock('../ui/utils/terminalUtils.js', () => ({ @@ -95,6 +97,7 @@ function isInkRenderMetrics( typeof m === 'object' && m !== null && 'output' in m && + // eslint-disable-next-line no-restricted-syntax typeof m['output'] === 'string' ); } @@ -254,13 +257,9 @@ class XtermStdout extends EventEmitter { return currentFrame !== ''; } - // If both are empty, it's a match. - // We consider undefined lastRenderOutput as effectively empty for this check - // to support hook testing where Ink may skip rendering completely. - if ( - (this.lastRenderOutput === undefined || expectedFrame === '') && - currentFrame === '' - ) { + // If Ink expects nothing (no new static content and no dynamic output), + // we consider it a match because the terminal buffer will just hold the historical static content. + if (expectedFrame === '') { return true; } @@ -268,8 +267,8 @@ class XtermStdout extends EventEmitter { return false; } - // If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match. - if (expectedFrame === '' || currentFrame === '') { + // If the terminal is empty but Ink expects something, it's not a match. + if (currentFrame === '') { return false; } @@ -377,15 +376,21 @@ export type RenderInstance = { capturedOverflowActions: OverflowActions | undefined; }; +export type RenderWithProvidersInstance = RenderInstance & { + simulateClick: ( + col: number, + row: number, + button?: 0 | 1 | 2, + ) => Promise; +}; + const instances: InkInstance[] = []; -// Wrapper around ink's render that ensures act() is called and uses Xterm for output -export const render = ( +export const render = async ( tree: React.ReactElement, terminalWidth?: number, -): Omit< - RenderInstance, - 'capturedOverflowState' | 'capturedOverflowActions' +): Promise< + Omit > => { const cols = terminalWidth ?? 100; // We use 1000 rows to avoid windows with incorrect snapshots if a correct @@ -414,11 +419,10 @@ export const render = ( stdout.clear(); act(() => { instance = inkRenderDirect(tree, { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion stdout: stdout as unknown as NodeJS.WriteStream, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stderr: stderr as unknown as NodeJS.WriteStream, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stdin: stdin as unknown as NodeJS.ReadStream, debug: false, exitOnCtrlC: false, @@ -435,6 +439,8 @@ export const render = ( instances.push(instance); + await stdout.waitUntilReady(); + return { rerender: (newTree: React.ReactElement) => { act(() => { @@ -485,55 +491,18 @@ export const simulateClick = async ( }); }; -let mockConfigInternal: Config | undefined; - -const getMockConfigInternal = (): Config => { - if (!mockConfigInternal) { - mockConfigInternal = makeFakeConfig({ - targetDir: os.tmpdir(), - enableEventDrivenScheduler: true, - }); - } - return mockConfigInternal; -}; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const configProxy = new Proxy({} as Config, { - get(_target, prop) { - if (prop === 'getTargetDir') { - return () => - '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long'; - } - if (prop === 'getUseBackgroundColor') { - return () => true; - } - const internal = getMockConfigInternal(); - if (prop in internal) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return internal[prop as keyof typeof internal]; - } - throw new Error(`mockConfig does not have property ${String(prop)}`); - }, -}); - -export const mockSettings = new LoadedSettings( - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - true, - [], -); +export const mockSettings = createMockSettings(); // A minimal mock UIState to satisfy the context provider. // Tests that need specific UIState values should provide their own. const baseMockUiState = { + history: [], renderMarkdown: true, streamingState: StreamingState.Idle, terminalWidth: 100, terminalHeight: 40, currentModel: 'gemini-pro', - terminalBackgroundColor: 'black', + terminalBackgroundColor: 'black' as const, cleanUiDetailsVisible: false, allowPlanMode: true, activePtyId: undefined, @@ -547,6 +516,14 @@ const baseMockUiState = { }, hintMode: false, hintBuffer: '', + bannerData: { + defaultText: '', + warningText: '', + }, + bannerVisible: false, + nightly: false, + updateInfo: null, + pendingHistoryItems: [], }; export const mockAppState: AppState = { @@ -586,6 +563,8 @@ const mockUIActions: UIActions = { handleClearScreen: vi.fn(), handleProQuotaChoice: vi.fn(), handleValidationChoice: vi.fn(), + handleOverageMenuChoice: vi.fn(), + handleEmptyWalletChoice: vi.fn(), setQueueErrorMessage: vi.fn(), popAllMessages: vi.fn(), handleApiKeySubmit: vi.fn(), @@ -608,6 +587,7 @@ const mockUIActions: UIActions = { handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), getPreferredEditor: vi.fn(), + clearAccountSuspension: vi.fn(), }; let capturedOverflowState: OverflowState | undefined; @@ -620,7 +600,7 @@ const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ return <>{children}; }; -export const renderWithProviders = ( +export const renderWithProviders = async ( component: React.ReactElement, { shellFocus = true, @@ -628,9 +608,7 @@ export const renderWithProviders = ( uiState: providedUiState, width, mouseEventsEnabled = false, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - config = configProxy as unknown as Config, - useAlternateBuffer = true, + config, uiActions, persistentState, appState = mockAppState, @@ -641,7 +619,6 @@ export const renderWithProviders = ( width?: number; mouseEventsEnabled?: boolean; config?: Config; - useAlternateBuffer?: boolean; uiActions?: Partial; persistentState?: { get?: typeof persistentStateMock.get; @@ -649,27 +626,18 @@ export const renderWithProviders = ( }; appState?: AppState; } = {}, -): RenderInstance & { - simulateClick: ( - col: number, - row: number, - button?: 0 | 1 | 2, - ) => Promise; -} => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +): Promise => { const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { get(target, prop) { if (prop in target) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return target[prop as keyof typeof target]; } // For properties not in the base mock or provided state, // we'll check the original proxy to see if it's a defined but // unprovided property, and if not, throw. if (prop in baseMockUiState) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return baseMockUiState[prop as keyof typeof baseMockUiState]; } throw new Error(`mockUiState does not have property ${String(prop)}`); @@ -687,15 +655,14 @@ export const renderWithProviders = ( persistentStateMock.mockClear(); const terminalWidth = width ?? baseState.terminalWidth; - let finalSettings = settings; - if (useAlternateBuffer !== undefined) { - finalSettings = createMockSettings({ - ...settings.merged, - ui: { - ...settings.merged.ui, - useAlternateBuffer, - }, - }); + + if (!config) { + config = await loadCliConfig( + settings.merged, + 'random-session-id', + {} as unknown as CliArgs, + { cwd: '/' }, + ); } const mainAreaWidth = terminalWidth; @@ -724,12 +691,12 @@ export const renderWithProviders = ( capturedOverflowState = undefined; capturedOverflowActions = undefined; - const renderResult = render( + const wrapWithProviders = (comp: React.ReactElement) => ( - + - + - {component} + {comp} @@ -777,12 +744,19 @@ export const renderWithProviders = ( - , + + ); + + const renderResult = await render( + wrapWithProviders(component), terminalWidth, ); return { ...renderResult, + rerender: (newComponent: React.ReactElement) => { + renderResult.rerender(wrapWithProviders(newComponent)); + }, capturedOverflowState, capturedOverflowActions, simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => @@ -790,22 +764,20 @@ export const renderWithProviders = ( }; }; -export function renderHook( +export async function renderHook( renderCallback: (props: Props) => Result, options?: { initialProps?: Props; wrapper?: React.ComponentType<{ children: React.ReactNode }>; }, -): { +): Promise<{ result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; -} { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +}> { const result = { current: undefined as unknown as Result }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion let currentProps = options?.initialProps as Props; function TestComponent({ @@ -826,21 +798,18 @@ export function renderHook( let waitUntilReady: () => Promise = async () => {}; let generateSvg: () => string = () => ''; - act(() => { - const renderResult = render( - - - , - ); - inkRerender = renderResult.rerender; - unmount = renderResult.unmount; - waitUntilReady = renderResult.waitUntilReady; - generateSvg = renderResult.generateSvg; - }); + const renderResult = await render( + + + , + ); + inkRerender = renderResult.rerender; + unmount = renderResult.unmount; + waitUntilReady = renderResult.waitUntilReady; + generateSvg = renderResult.generateSvg; function rerender(props?: Props) { if (arguments.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion currentProps = props as Props; } act(() => { @@ -855,7 +824,7 @@ export function renderHook( return { result, rerender, unmount, waitUntilReady, generateSvg }; } -export function renderHookWithProviders( +export async function renderHookWithProviders( renderCallback: (props: Props) => Result, options: { initialProps?: Props; @@ -867,16 +836,14 @@ export function renderHookWithProviders( width?: number; mouseEventsEnabled?: boolean; config?: Config; - useAlternateBuffer?: boolean; } = {}, -): { +): Promise<{ result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; -} { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +}> { const result = { current: undefined as unknown as Result }; let setPropsFn: ((props: Props) => void) | undefined; @@ -893,12 +860,12 @@ export function renderHookWithProviders( const Wrapper = options.wrapper || (({ children }) => <>{children}); - let renderResult: ReturnType; + let renderResult: RenderWithProvidersInstance; - act(() => { - renderResult = renderWithProviders( + await act(async () => { + renderResult = await renderWithProviders( - {/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */} + {} , options, @@ -908,7 +875,6 @@ export function renderHookWithProviders( function rerender(newProps?: Props) { act(() => { if (arguments.length > 0 && setPropsFn) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion setPropsFn(newProps as Props); } else if (forceUpdateFn) { forceUpdateFn(); diff --git a/packages/cli/src/test-utils/settings.ts b/packages/cli/src/test-utils/settings.ts index dd498b6625..20d0613f83 100644 --- a/packages/cli/src/test-utils/settings.ts +++ b/packages/cli/src/test-utils/settings.ts @@ -46,23 +46,20 @@ export const createMockSettings = ( workspace, isTrusted, errors, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment merged: mergedOverride, ...settingsOverrides } = overrides; const loaded = new LoadedSettings( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (system as any) || { path: '', settings: {}, originalSettings: {} }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (systemDefaults as any) || { path: '', settings: {}, originalSettings: {} }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (user as any) || { path: '', settings: settingsOverrides, originalSettings: settingsOverrides, }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (workspace as any) || { path: '', settings: {}, originalSettings: {} }, isTrusted ?? true, errors || [], @@ -76,7 +73,6 @@ export const createMockSettings = ( // Assign any function overrides (e.g., vi.fn() for methods) for (const key in overrides) { if (typeof overrides[key] === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment (loaded as any)[key] = overrides[key]; } } diff --git a/packages/cli/src/test-utils/svg.ts b/packages/cli/src/test-utils/svg.ts index 10528ca6b7..92d3f53c2f 100644 --- a/packages/cli/src/test-utils/svg.ts +++ b/packages/cli/src/test-utils/svg.ts @@ -89,6 +89,7 @@ export const generateSvgForTerminal = (terminal: Terminal): string => { break; } } + if (contentRows === 0) contentRows = 1; // Minimum 1 row const width = terminal.cols * charWidth + padding * 2; @@ -113,6 +114,9 @@ export const generateSvgForTerminal = (terminal: Terminal): string => { let currentFgHex: string | null = null; let currentBgHex: string | null = null; + let currentIsBold = false; + let currentIsItalic = false; + let currentIsUnderline = false; let currentBlockStartCol = -1; let currentBlockText = ''; let currentBlockNumCells = 0; @@ -128,12 +132,20 @@ export const generateSvgForTerminal = (terminal: Terminal): string => { svg += ` `; } - if (currentBlockText.trim().length > 0) { + if (currentBlockText.trim().length > 0 || currentIsUnderline) { const fill = currentFgHex || '#ffffff'; // Default text color const textWidth = currentBlockNumCells * charWidth; + + let extraAttrs = ''; + if (currentIsBold) extraAttrs += ' font-weight="bold"'; + if (currentIsItalic) extraAttrs += ' font-style="italic"'; + if (currentIsUnderline) + extraAttrs += ' text-decoration="underline"'; + // Use textLength to ensure the block fits exactly into its designated cells - svg += ` ${escapeXml(currentBlockText)} -`; + const textElement = `${escapeXml(currentBlockText)}`; + + svg += ` ${textElement}\n`; } } } @@ -164,17 +176,27 @@ export const generateSvgForTerminal = (terminal: Terminal): string => { bgHex = tempFgHex || '#ffffff'; } + const isBold = !!cell.isBold(); + const isItalic = !!cell.isItalic(); + const isUnderline = !!cell.isUnderline(); + let chars = cell.getChars(); if (chars === '') chars = ' '.repeat(cellWidth); if ( fgHex !== currentFgHex || bgHex !== currentBgHex || + isBold !== currentIsBold || + isItalic !== currentIsItalic || + isUnderline !== currentIsUnderline || currentBlockStartCol === -1 ) { finalizeBlock(x); currentFgHex = fgHex; currentBgHex = bgHex; + currentIsBold = isBold; + currentIsItalic = isItalic; + currentIsUnderline = isUnderline; currentBlockStartCol = x; currentBlockText = chars; currentBlockNumCells = cellWidth; @@ -185,6 +207,7 @@ export const generateSvgForTerminal = (terminal: Terminal): string => { } finalizeBlock(line.length); } + svg += ` \n`; return svg; }; diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index d96bfe3071..950363f6a8 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest'; import type React from 'react'; import { renderWithProviders } from '../test-utils/render.js'; +import { createMockSettings } from '../test-utils/settings.js'; import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; import { App } from './App.js'; import { type UIState } from './contexts/UIStateContext.js'; @@ -93,14 +94,10 @@ describe('App', () => { }; it('should render main content and composer when not quitting', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: mockUIState, - useAlternateBuffer: false, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -114,14 +111,10 @@ describe('App', () => { quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: quittingUIState, - useAlternateBuffer: false, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: quittingUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }); expect(lastFrame()).toContain('Quitting...'); unmount(); @@ -135,14 +128,10 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: quittingUIState, - useAlternateBuffer: true, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: quittingUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('HistoryItemDisplay'); expect(lastFrame()).toContain('Quitting...'); @@ -155,13 +144,10 @@ describe('App', () => { dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: dialogUIState, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -181,13 +167,10 @@ describe('App', () => { [stateKey]: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`); unmount(); @@ -197,13 +180,10 @@ describe('App', () => { it('should render ScreenReaderAppLayout when screen reader is enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: mockUIState, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Footer'); @@ -215,13 +195,10 @@ describe('App', () => { it('should render DefaultAppLayout when screen reader is not enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: mockUIState, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -265,18 +242,15 @@ describe('App', () => { ], } as UIState; - const configWithExperiment = makeFakeConfig(); + const configWithExperiment = makeFakeConfig({ useAlternateBuffer: true }); vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: stateWithConfirmingTool, - config: configWithExperiment, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: stateWithConfirmingTool, + config: configWithExperiment, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -289,26 +263,20 @@ describe('App', () => { describe('Snapshots', () => { it('renders default layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: mockUIState, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders screen reader layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: mockUIState, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toMatchSnapshot(); unmount(); }); @@ -318,13 +286,10 @@ describe('App', () => { ...mockUIState, dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { - uiState: dialogUIState, - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index b3610a6d72..6696b0630b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -16,7 +16,7 @@ import { } from 'vitest'; import { render, cleanup, persistentStateMock } from '../test-utils/render.js'; import { waitFor } from '../test-utils/async.js'; -import { act, useContext, type ReactElement } from 'react'; +import { act, useContext } from 'react'; import { AppContainer } from './AppContainer.js'; import { SettingsContext } from './contexts/SettingsContext.js'; import { type TrackedToolCall } from './hooks/useToolScheduler.js'; @@ -65,6 +65,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...actual, + createCache: + actual.createCache ?? + ((() => { + const cache = new Map(); + return { + clear: () => cache.clear(), + getOrCreate: (key: K, factory: () => V) => { + if (!cache.has(key)) { + cache.set(key, factory()); + } + return cache.get(key)!; + }, + }; + }) as typeof actual.createCache), coreEvents: mockCoreEvents, IdeClient: mockIdeClient, writeToStdout: vi.fn((...args) => @@ -95,7 +109,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); import ansiEscapes from 'ansi-escapes'; -import { mergeSettings, type LoadedSettings } from '../config/settings.js'; +import { type LoadedSettings } from '../config/settings.js'; +import { createMockSettings } from '../test-utils/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { StreamingState } from './types.js'; @@ -160,6 +175,7 @@ vi.mock('./hooks/useIdeTrustListener.js'); vi.mock('./hooks/useMessageQueue.js'); vi.mock('./hooks/useApprovalModeIndicator.js'); vi.mock('./hooks/useGitBranchName.js'); +vi.mock('./hooks/useExtensionUpdates.js'); vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); vi.mock('./components/shared/text-buffer.js'); @@ -210,7 +226,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; -import { useConsoleMessages } from './hooks/useConsoleMessages.js'; +import { useErrorCount } from './hooks/useConsoleMessages.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; @@ -218,6 +234,10 @@ import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import { + useConfirmUpdateRequests, + useExtensionUpdates, +} from './hooks/useExtensionUpdates.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; @@ -227,10 +247,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import * as useKeypressModule from './hooks/useKeypress.js'; import { useSuspend } from './hooks/useSuspend.js'; -import { measureElement } from 'ink'; -import { useTerminalSize } from './hooks/useTerminalSize.js'; import { - ShellExecutionService, writeToStdout, enableMouseEvents, disableMouseEvents, @@ -247,6 +264,15 @@ describe('AppContainer State Management', () => { let mockInitResult: InitializationResult; let mockExtensionManager: MockedObject; + type AppContainerProps = { + settings?: LoadedSettings; + config?: Config; + version?: string; + initResult?: InitializationResult; + startupWarnings?: StartupWarning[]; + resumedSessionData?: ResumedSessionData; + }; + // Helper to generate the AppContainer JSX for render and rerender const getAppContainer = ({ settings = mockSettings, @@ -255,14 +281,7 @@ describe('AppContainer State Management', () => { initResult = mockInitResult, startupWarnings, resumedSessionData, - }: { - settings?: LoadedSettings; - config?: Config; - version?: string; - initResult?: InitializationResult; - startupWarnings?: StartupWarning[]; - resumedSessionData?: ResumedSessionData; - } = {}) => ( + }: AppContainerProps = {}) => ( @@ -279,7 +298,7 @@ describe('AppContainer State Management', () => { ); // Helper to render the AppContainer - const renderAppContainer = (props?: Parameters[0]) => + const renderAppContainer = async (props?: AppContainerProps) => render(getAppContainer(props)); // Create typed mocks for all hooks @@ -291,7 +310,7 @@ describe('AppContainer State Management', () => { const mockedUseSettingsCommand = useSettingsCommand as Mock; const mockedUseModelCommand = useModelCommand as Mock; const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; - const mockedUseConsoleMessages = useConsoleMessages as Mock; + const mockedUseConsoleMessages = useErrorCount as Mock; const mockedUseGeminiStream = useGeminiStream as Mock; const mockedUseVim = useVim as Mock; const mockedUseFolderTrust = useFolderTrust as Mock; @@ -299,6 +318,8 @@ describe('AppContainer State Management', () => { const mockedUseMessageQueue = useMessageQueue as Mock; const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock; const mockedUseGitBranchName = useGitBranchName as Mock; + const mockedUseConfirmUpdateRequests = useConfirmUpdateRequests as Mock; + const mockedUseExtensionUpdates = useExtensionUpdates as Mock; const mockedUseVimMode = useVimMode as Mock; const mockedUseSessionStats = useSessionStats as Mock; const mockedUseTextBuffer = useTextBuffer as Mock; @@ -391,9 +412,9 @@ describe('AppContainer State Management', () => { confirmationRequest: null, }); mockedUseConsoleMessages.mockReturnValue({ - consoleMessages: [], + errorCount: 0, handleNewMessage: vi.fn(), - clearConsoleMessages: vi.fn(), + clearErrorCount: vi.fn(), }); mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); @@ -451,6 +472,15 @@ describe('AppContainer State Management', () => { isFocused: true, hasReceivedFocusEvent: true, }); + mockedUseConfirmUpdateRequests.mockReturnValue({ + addConfirmUpdateExtensionRequest: vi.fn(), + confirmUpdateExtensionRequests: [], + }); + mockedUseExtensionUpdates.mockReturnValue({ + extensionsUpdateState: new Map(), + extensionsUpdateStateInternal: new Map(), + dispatchExtensionStateUpdate: vi.fn(), + }); // Mock Config mockConfig = makeFakeConfig(); @@ -471,23 +501,18 @@ describe('AppContainer State Management', () => { ); // Mock LoadedSettings - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - mockSettings = { - merged: { - ...defaultMergedSettings, - hideBanner: false, - hideFooter: false, - hideTips: false, - showMemoryUsage: false, - theme: 'default', - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: false, - hideWindowTitle: false, - useAlternateBuffer: false, - }, + mockSettings = createMockSettings({ + hideBanner: false, + hideFooter: false, + hideTips: false, + showMemoryUsage: false, + theme: 'default', + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + useAlternateBuffer: false, }, - } as unknown as LoadedSettings; + }); // Mock InitializationResult mockInitResult = { @@ -505,13 +530,9 @@ describe('AppContainer State Management', () => { describe('Basic Rendering', () => { it('renders without crashing with minimal props', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('renders with startup warnings', async () => { @@ -528,44 +549,32 @@ describe('AppContainer State Management', () => { }, ]; - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ startupWarnings }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ startupWarnings }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('shows full UI details by default', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState.cleanUiDetailsVisible).toBe(true); - }); - unmount!(); + expect(capturedUIState.cleanUiDetailsVisible).toBe(true); + unmount(); }); it('starts in minimal UI mode when Focus UI preference is persisted', async () => { persistentStateMock.get.mockReturnValueOnce(true); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: mockSettings, - }); - unmount = result.unmount; - }); + }), + ); - await waitFor(() => { - expect(capturedUIState.cleanUiDetailsVisible).toBe(false); - }); + expect(capturedUIState.cleanUiDetailsVisible).toBe(false); expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled'); - unmount!(); + unmount(); }); }); @@ -600,15 +609,9 @@ describe('AppContainer State Management', () => { ], }); - let unmount: (() => void) | undefined; - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => - expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), - ); + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); expect( terminalNotificationsMocks.buildRunEventNotificationContent, ).toHaveBeenCalledWith( @@ -617,9 +620,7 @@ describe('AppContainer State Management', () => { }), ); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('does not send attention notification when terminal is focused', async () => { @@ -652,19 +653,13 @@ describe('AppContainer State Management', () => { ], }); - let unmount: (() => void) | undefined; - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('sends attention notification when focus reporting is unavailable', async () => { @@ -697,19 +692,11 @@ describe('AppContainer State Management', () => { ], }); - let unmount: (() => void) | undefined; - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => - expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), - ); + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('sends a macOS notification when a response completes while unfocused', async () => { @@ -723,35 +710,24 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; - - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); + const { unmount, rerender } = await act(async () => renderAppContainer()); currentStreamingState = 'idle'; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); - await waitFor(() => - expect( - terminalNotificationsMocks.buildRunEventNotificationContent, - ).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'session_complete', - detail: 'Gemini CLI finished responding.', - }), - ), + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), ); expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('sends completion notification when focus reporting is unavailable', async () => { @@ -765,34 +741,23 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; - - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); + const { unmount, rerender } = await act(async () => renderAppContainer()); currentStreamingState = 'idle'; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); - await waitFor(() => - expect( - terminalNotificationsMocks.buildRunEventNotificationContent, - ).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'session_complete', - detail: 'Gemini CLI finished responding.', - }), - ), + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), ); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('does not send completion notification when another action-required dialog is pending', async () => { @@ -810,27 +775,18 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; - - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); + const { unmount, rerender } = await act(async () => renderAppContainer()); currentStreamingState = 'idle'; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('can send repeated attention notifications for the same key after pending state clears', async () => { @@ -866,24 +822,15 @@ describe('AppContainer State Management', () => { pendingHistoryItems, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; + const { unmount, rerender } = await act(async () => renderAppContainer()); - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); - - await waitFor(() => - expect( - terminalNotificationsMocks.notifyViaTerminal, - ).toHaveBeenCalledTimes(1), - ); + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(1); pendingHistoryItems = []; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); pendingHistoryItems = [ @@ -908,18 +855,14 @@ describe('AppContainer State Management', () => { }, ]; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); - await waitFor(() => - expect( - terminalNotificationsMocks.notifyViaTerminal, - ).toHaveBeenCalledTimes(2), - ); + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(2); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('initializes with theme error from initialization result', async () => { @@ -928,112 +871,82 @@ describe('AppContainer State Management', () => { themeError: 'Failed to load theme', }; - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ initResult: initResultWithError, - }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); - it('handles debug mode state', () => { + it('handles debug mode state', async () => { const debugConfig = makeFakeConfig(); vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); - expect(() => { - renderAppContainer({ config: debugConfig }); - }).not.toThrow(); + const { unmount } = await act(async () => + renderAppContainer({ config: debugConfig }), + ); + unmount(); }); }); describe('Context Providers', () => { it('provides AppContext with correct values', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ version: '2.0.0' }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => + renderAppContainer({ version: '2.0.0' }), + ); + expect(capturedUIState).toBeTruthy(); // Should render and unmount cleanly - expect(() => unmount!()).not.toThrow(); + unmount(); }); it('provides UIStateContext with state management', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('provides UIActionsContext with action handlers', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('provides ConfigContext with config object', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); }); describe('Settings Integration', () => { it('handles settings with all display options disabled', async () => { - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const settingsAllHidden = { - merged: { - ...defaultMergedSettings, - hideBanner: true, - hideFooter: true, - hideTips: true, - showMemoryUsage: false, - }, - } as unknown as LoadedSettings; - - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ settings: settingsAllHidden }); - unmount = result.unmount; + const settingsAllHidden = createMockSettings({ + hideBanner: true, + hideFooter: true, + hideTips: true, + showMemoryUsage: false, }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsAllHidden }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('handles settings with memory usage enabled', async () => { - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const settingsWithMemory = { - merged: { - ...defaultMergedSettings, - hideBanner: false, - hideFooter: false, - hideTips: false, - showMemoryUsage: true, - }, - } as unknown as LoadedSettings; - - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ settings: settingsWithMemory }); - unmount = result.unmount; + const settingsWithMemory = createMockSettings({ + showMemoryUsage: true, }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsWithMemory }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); }); @@ -1041,13 +954,11 @@ describe('AppContainer State Management', () => { it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( 'handles version format: %s', async (version) => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ version }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ version }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }, ); }); @@ -1060,32 +971,30 @@ describe('AppContainer State Management', () => { }); // Should still render without crashing - errors should be handled internally - const { unmount } = renderAppContainer({ config: errorConfig }); + const { unmount } = await act(async () => + renderAppContainer({ config: errorConfig }), + ); unmount(); }); it('handles undefined settings gracefully', async () => { - const undefinedSettings = { - merged: mergeSettings({}, {}, {}, {}, true), - } as LoadedSettings; + const undefinedSettings = createMockSettings(); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ settings: undefinedSettings }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ settings: undefinedSettings }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); }); describe('Provider Hierarchy', () => { - it('establishes correct provider nesting order', () => { + it('establishes correct provider nesting order', async () => { // This tests that all the context providers are properly nested // and that the component tree can be built without circular dependencies - const { unmount } = renderAppContainer(); + const { unmount } = await act(async () => renderAppContainer()); - expect(() => unmount()).not.toThrow(); + unmount(); }); }); @@ -1117,40 +1026,32 @@ describe('AppContainer State Management', () => { filePath: '/tmp/test-session.json', }; - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: mockResumedSessionData, - }); - unmount = result.unmount; - }); - await act(async () => { - unmount(); - }); + }), + ); + unmount(); }); it('renders without resumed session data', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: undefined, - }); - unmount = result.unmount; - }); - await act(async () => { - unmount(); - }); + }), + ); + unmount(); }); - it('initializes chat recording service when config has it', () => { + it('initializes chat recording service when config has it', async () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), @@ -1170,18 +1071,19 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - expect(() => { + const { unmount } = await act(async () => renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, - }); - }).not.toThrow(); + }), + ); + unmount(); }); }); describe('Session Recording Integration', () => { - it('provides chat recording service configuration', () => { + it('provides chat recording service configuration', async () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), @@ -1207,23 +1109,24 @@ describe('AppContainer State Management', () => { 'test-session-123', ); - expect(() => { + const { unmount } = await act(async () => renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, - }); - }).not.toThrow(); + }), + ); // Verify the recording service structure is correct expect(configWithRecording.getGeminiClient).toBeDefined(); expect(mockGeminiClient.getChatRecordingService).toBeDefined(); expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); + unmount(); }); - it('handles session recording when messages are added', () => { + it('handles session recording when messages are added', async () => { const mockRecordMessage = vi.fn(); const mockRecordMessageTokens = vi.fn(); @@ -1246,22 +1149,25 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - renderAppContainer({ - config: configWithRecording, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - }); + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); // The actual recording happens through the useHistory hook // which would be triggered by user interactions expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); + unmount(); }); }); describe('Session Resume Flow', () => { - it('accepts resumed session data', () => { + it('accepts resumed session data', async () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => true), @@ -1307,22 +1213,23 @@ describe('AppContainer State Management', () => { filePath: '/tmp/resumed-session.json', }; - expect(() => { + const { unmount } = await act(async () => renderAppContainer({ config: configWithClient, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: resumedData, - }); - }).not.toThrow(); + }), + ); // Verify the resume functionality structure is in place expect(mockGeminiClient.resumeChat).toBeDefined(); expect(resumedData.conversation.messages).toHaveLength(2); + unmount(); }); - it('does not attempt resume when client is not initialized', () => { + it('does not attempt resume when client is not initialized', async () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => false), // Not initialized @@ -1347,21 +1254,24 @@ describe('AppContainer State Management', () => { filePath: '/tmp/session.json', }; - renderAppContainer({ - config: configWithClient, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - resumedSessionData: resumedData, - }); + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithClient, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: resumedData, + }), + ); // Should not call resumeChat when client is not initialized expect(mockResumeChat).not.toHaveBeenCalled(); + unmount(); }); }); describe('Token Counting from Session Stats', () => { - it('tracks token counts from session messages', () => { + it('tracks token counts from session messages', async () => { // Session stats are provided through the SessionStatsProvider context // in the real app, not through the config directly const mockChatRecordingService = { @@ -1389,33 +1299,30 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - renderAppContainer({ - config: configWithRecording, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - }); + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); // In the actual app, these stats would be displayed in components // and updated as messages are processed through the recording service expect(mockChatRecordingService.recordMessageTokens).toBeDefined(); expect(mockChatRecordingService.getCurrentConversation).toBeDefined(); + unmount(); }); }); describe('Quota and Fallback Integration', () => { it('passes a null proQuotaRequest to UIStateContext by default', async () => { // The default mock from beforeEach already sets proQuotaRequest to null - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => { - // Assert that the context value is as expected - expect(capturedUIState.quota.proQuotaRequest).toBeNull(); - }); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + // Assert that the context value is as expected + expect(capturedUIState.quota.proQuotaRequest).toBeNull(); + unmount(); }); it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => { @@ -1431,16 +1338,10 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => { - // Assert: The mock request is correctly passed through the context - expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); - }); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + // Assert: The mock request is correctly passed through the context + expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); + unmount(); }); it('passes the handleProQuotaChoice function to UIActionsContext', async () => { @@ -1452,22 +1353,16 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => { - // Assert: The action in the context is the mock handler we provided - expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); - }); + const { unmount } = await act(async () => renderAppContainer()); + // Assert: The action in the context is the mock handler we provided + expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); // You can even verify that the plumbed function is callable act(() => { capturedUIActions.handleProQuotaChoice('retry_later'); }); expect(mockHandler).toHaveBeenCalledWith('retry_later'); - unmount!(); + unmount(); }); }); @@ -1483,20 +1378,14 @@ describe('AppContainer State Management', () => { expect(stdout).toBe(mocks.mockStdout); }); - it('should update terminal title with Working… when showStatusInTitle is false', () => { + it('should update terminal title with Working… when showStatusInTitle is false', async () => { // Arrange: Set up mock settings with showStatusInTitle disabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithShowStatusFalse = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: false, - hideWindowTitle: false, - }, + const mockSettingsWithShowStatusFalse = createMockSettings({ + ui: { + showStatusInTitle: false, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state as Active mockedUseGeminiStream.mockReturnValue({ @@ -1506,9 +1395,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithShowStatusFalse, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithShowStatusFalse, + }), + ); // Assert: Check that title was updated with "Working…" const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1522,19 +1413,14 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use legacy terminal title when dynamicWindowTitle is false', () => { + it('should use legacy terminal title when dynamicWindowTitle is false', async () => { // Arrange: Set up mock settings with dynamicWindowTitle disabled - const mockSettingsWithDynamicTitleFalse = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - dynamicWindowTitle: false, - hideWindowTitle: false, - }, + const mockSettingsWithDynamicTitleFalse = createMockSettings({ + ui: { + dynamicWindowTitle: false, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ @@ -1544,9 +1430,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithDynamicTitleFalse, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithDynamicTitleFalse, + }), + ); // Assert: Check that legacy title was used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1560,25 +1448,21 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should not update terminal title when hideWindowTitle is true', () => { + it('should not update terminal title when hideWindowTitle is true', async () => { // Arrange: Set up mock settings with hideWindowTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithHideTitleTrue = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: true, - }, + const mockSettingsWithHideTitleTrue = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: true, }, - } as unknown as LoadedSettings; + }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithHideTitleTrue, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithHideTitleTrue, + }), + ); // Assert: Check that no title-related writes occurred const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1589,20 +1473,14 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should update terminal title with thought subject when in active state', () => { + it('should update terminal title with thought subject when in active state', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const thoughtSubject = 'Processing request'; @@ -1613,9 +1491,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that title was updated with thought subject and suffix const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1629,28 +1509,24 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should update terminal title with default text when in Idle state and no thought subject', () => { + it('should update terminal title with default text when in Idle state and no thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state as Idle with no thought mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that title was updated with default Idle text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1666,18 +1542,12 @@ describe('AppContainer State Management', () => { it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; @@ -1688,13 +1558,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: mockSettingsWithTitleEnabled, - }); - unmount = result.unmount; - }); + }), + ); // Assert: Check that title was updated with confirmation text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1705,7 +1573,7 @@ describe('AppContainer State Management', () => { expect(titleWrites[0][0]).toBe( `\x1b]0;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`, ); - unmount!(); + unmount(); }); describe('Shell Focus Action Required', () => { @@ -1729,17 +1597,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty but not focused mockedUseGeminiStream.mockReturnValue({ @@ -1756,9 +1619,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container (embeddedShellFocused is false by default in state) - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Initially it should show the working status const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1788,17 +1653,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty with redirection active mockedUseGeminiStream.mockReturnValue({ @@ -1822,9 +1682,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Fast-forward time by 65 seconds - should still NOT be Action Required await act(async () => { @@ -1858,17 +1720,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty with NO output since operation started (silent) mockedUseGeminiStream.mockReturnValue({ @@ -1884,9 +1741,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Fast-forward time by 65 seconds await act(async () => { @@ -1908,17 +1767,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty but not focused let lastOutputTime = startTime + 1000; @@ -1934,9 +1788,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container - const { unmount, rerender } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount, rerender } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Fast-forward time by 20 seconds await act(async () => { @@ -1990,20 +1846,14 @@ describe('AppContainer State Management', () => { }); }); - it('should pad title to exactly 80 characters', () => { + it('should pad title to exactly 80 characters', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought with a short subject const shortTitle = 'Short'; @@ -2014,9 +1864,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that title is padded to exactly 80 characters const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -2031,20 +1883,14 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use correct ANSI escape code format', () => { + it('should use correct ANSI escape code format', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const title = 'Test Title'; @@ -2055,9 +1901,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that the correct ANSI escape sequence is used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -2070,19 +1918,14 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use CLI_TITLE environment variable when set', () => { + it('should use CLI_TITLE environment variable when set', async () => { // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) - const mockSettingsWithTitleDisabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: false, - hideWindowTitle: false, - }, + const mockSettingsWithTitleDisabled = createMockSettings({ + ui: { + showStatusInTitle: false, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock CLI_TITLE environment variable vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); @@ -2094,9 +1937,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleDisabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleDisabled, + }), + ); // Assert: Check that title was updated with CLI_TITLE value const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -2122,7 +1967,7 @@ describe('AppContainer State Management', () => { }); it('should set and clear the queue error message after a timeout', async () => { - const { rerender, unmount } = renderAppContainer(); + const { rerender, unmount } = await act(async () => renderAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2144,7 +1989,7 @@ describe('AppContainer State Management', () => { }); it('should reset the timer if a new error message is set', async () => { - const { rerender, unmount } = renderAppContainer(); + const { rerender, unmount } = await act(async () => renderAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2181,45 +2026,16 @@ describe('AppContainer State Management', () => { }); }); - describe('Terminal Height Calculation', () => { - const mockedMeasureElement = measureElement as Mock; - const mockedUseTerminalSize = useTerminalSize as Mock; - - it('should prevent terminal height from being less than 1', async () => { - const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty'); - // Arrange: Simulate a small terminal and a large footer - mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 }); - mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen - - mockedUseGeminiStream.mockReturnValue({ - ...DEFAULT_GEMINI_STREAM_MOCK, - activePtyId: 'some-id', - }); - - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(resizePtySpy).toHaveBeenCalled()); - const lastCall = - resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1]; - // Check the height argument specifically - expect(lastCall[2]).toBe(1); - unmount!(); - }); - }); - describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { let mockHandleSlashCommand: Mock; let mockCancelOngoingRequest: Mock; let rerender: () => void; let unmount: () => void; - let stdin: ReturnType['stdin']; + let stdin: Awaited>['stdin']; // Helper function to reduce boilerplate in tests const setupKeypressTest = async () => { - const renderResult = renderAppContainer(); + const renderResult = await act(async () => renderAppContainer()); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); @@ -2433,7 +2249,7 @@ describe('AppContainer State Management', () => { activePtyId: 1, }); - const renderResult = render(getAppContainer()); + const renderResult = await act(async () => render(getAppContainer())); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2551,7 +2367,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; const setupShortcutsVisibilityTest = async () => { - const renderResult = renderAppContainer(); + const renderResult = await act(async () => renderAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2627,9 +2443,7 @@ describe('AppContainer State Management', () => { await act(async () => { rerender(); }); - await waitFor(() => { - expect(capturedUIState.shortcutsHelpVisible).toBe(false); - }); + expect(capturedUIState.shortcutsHelpVisible).toBe(false); unmount(); }); @@ -2658,9 +2472,7 @@ describe('AppContainer State Management', () => { await act(async () => { rerender(); }); - await waitFor(() => { - expect(capturedUIState.shortcutsHelpVisible).toBe(false); - }); + expect(capturedUIState.shortcutsHelpVisible).toBe(false); unmount(); }); @@ -2669,24 +2481,20 @@ describe('AppContainer State Management', () => { describe('Copy Mode (CTRL+S)', () => { let rerender: () => void; let unmount: () => void; - let stdin: ReturnType['stdin']; + let stdin: Awaited>['stdin']; const setupCopyModeTest = async ( isAlternateMode = false, childHandler?: Mock, ) => { + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue( + isAlternateMode, + ); + // Update settings for this test run - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const testSettings = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - useAlternateBuffer: isAlternateMode, - }, - }, - } as unknown as LoadedSettings; + const testSettings = createMockSettings({ + ui: { useAlternateBuffer: isAlternateMode }, + }); function TestChild() { useKeypress(childHandler || (() => {}), { @@ -2711,7 +2519,7 @@ describe('AppContainer State Management', () => { ); - const renderResult = render(getTree(testSettings)); + const renderResult = await act(async () => render(getTree(testSettings))); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); @@ -2782,7 +2590,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should exit copy mode on any key press', async () => { + it('should exit copy mode on non-scroll key press', async () => { await setupCopyModeTest(isAlternateMode); // Enter copy mode @@ -2804,6 +2612,61 @@ describe('AppContainer State Management', () => { unmount(); }); + it('should not exit copy mode on PageDown and should pass it through', async () => { + const childHandler = vi.fn().mockReturnValue(false); + await setupCopyModeTest(true, childHandler); + + // Enter copy mode + act(() => { + stdin.write('\x13'); // Ctrl+S + }); + rerender(); + expect(disableMouseEvents).toHaveBeenCalled(); + + childHandler.mockClear(); + (enableMouseEvents as Mock).mockClear(); + + // PageDown should be passed through to lower-priority handlers. + act(() => { + stdin.write('\x1b[6~'); + }); + rerender(); + + expect(enableMouseEvents).not.toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'pagedown' }), + ); + unmount(); + }); + + it('should not exit copy mode on Shift+Down and should pass it through', async () => { + const childHandler = vi.fn().mockReturnValue(false); + await setupCopyModeTest(true, childHandler); + + // Enter copy mode + act(() => { + stdin.write('\x13'); // Ctrl+S + }); + rerender(); + expect(disableMouseEvents).toHaveBeenCalled(); + + childHandler.mockClear(); + (enableMouseEvents as Mock).mockClear(); + + act(() => { + stdin.write('\x1b[1;2B'); // Shift+Down + }); + rerender(); + + expect(enableMouseEvents).not.toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalled(); + expect(childHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'down', shift: true }), + ); + unmount(); + }); + it('should have higher priority than other priority listeners when enabled', async () => { // 1. Initial state with a child component's priority listener (already subscribed) // It should NOT handle Ctrl+S so we can enter copy mode. @@ -2846,15 +2709,10 @@ describe('AppContainer State Management', () => { closeModelDialog: vi.fn(), }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); expect(capturedUIState.isModelDialogOpen).toBe(true); - unmount!(); + unmount(); }); it('should provide model dialog actions in the UIActionsContext', async () => { @@ -2866,45 +2724,29 @@ describe('AppContainer State Management', () => { closeModelDialog: mockCloseModelDialog, }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); // Verify that the actions are correctly passed through context act(() => { capturedUIActions.closeModelDialog(); }); expect(mockCloseModelDialog).toHaveBeenCalled(); - unmount!(); + unmount(); }); }); describe('Agent Configuration Dialog Integration', () => { it('should initialize with dialog closed and no agent selected', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - + const { unmount } = await act(async () => renderAppContainer()); expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); - unmount!(); + unmount(); }); it('should update state when openAgentConfigDialog is called', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const agentDefinition = { name: 'test-agent' }; act(() => { @@ -2919,16 +2761,11 @@ describe('AppContainer State Management', () => { expect(capturedUIState.selectedAgentName).toBe('test-agent'); expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); - unmount!(); + unmount(); }); it('should clear state when closeAgentConfigDialog is called', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const agentDefinition = { name: 'test-agent' }; act(() => { @@ -2949,31 +2786,26 @@ describe('AppContainer State Management', () => { expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); - unmount!(); + unmount(); }); }); describe('CoreEvents Integration', () => { it('subscribes to UserFeedback and drains backlog on mount', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); expect(mockCoreEvents.on).toHaveBeenCalledWith( CoreEvent.UserFeedback, expect.any(Function), ); expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); - unmount!(); + unmount(); }); it('unsubscribes from UserFeedback on unmount', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2989,7 +2821,7 @@ describe('AppContainer State Management', () => { it('adds history item when UserFeedback event is received', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3025,7 +2857,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { @@ -3058,7 +2890,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3070,7 +2902,7 @@ describe('AppContainer State Management', () => { it('handles consent request events', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3107,7 +2939,7 @@ describe('AppContainer State Management', () => { it('unsubscribes from ConsentRequest on unmount', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3121,30 +2953,6 @@ describe('AppContainer State Management', () => { }); }); - describe('Shell Interaction', () => { - it('should not crash if resizing the pty fails', async () => { - const resizePtySpy = vi - .spyOn(ShellExecutionService, 'resizePty') - .mockImplementation(() => { - throw new Error('Cannot resize a pty that has already exited'); - }); - - mockedUseGeminiStream.mockReturnValue({ - ...DEFAULT_GEMINI_STREAM_MOCK, - activePtyId: 'some-pty-id', // Make sure activePtyId is set - }); - - // The main assertion is that the render does not throw. - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - - await waitFor(() => expect(resizePtySpy).toHaveBeenCalled()); - unmount!(); - }); - }); describe('Banner Text', () => { it('should render placeholder banner text for USE_GEMINI auth type', async () => { const config = makeFakeConfig(); @@ -3154,7 +2962,7 @@ describe('AppContainer State Management', () => { }); let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { @@ -3181,13 +2989,8 @@ describe('AppContainer State Management', () => { }); }); - it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + it('preserves buffer when cancelling, even if empty (user is in control)', async () => { + const { unmount } = await act(async () => renderAppContainer()); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3197,9 +3000,42 @@ describe('AppContainer State Management', () => { onCancelSubmit(false); }); - expect(mockSetText).toHaveBeenCalledWith(''); + // Should NOT modify buffer when cancelling - user is in control + expect(mockSetText).not.toHaveBeenCalled(); - unmount!(); + unmount(); + }); + + it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { + // Mock buffer with text that user typed while streaming (same as last message) + const promptText = 'What is Python?'; + mockedUseTextBuffer.mockReturnValue({ + text: promptText, + setText: mockSetText, + }); + + // Mock input history with same message + mockedUseInputHistoryStore.mockReturnValue({ + inputHistory: [promptText], + addInput: vi.fn(), + initializeFromLogger: vi.fn(), + }); + + const { unmount } = await act(async () => renderAppContainer()); + + const { onCancelSubmit } = extractUseGeminiStreamArgs( + mockedUseGeminiStream.mock.lastCall!, + ); + + act(() => { + // Simulate Escape key cancelling streaming (shouldRestorePrompt=false) + onCancelSubmit(false); + }); + + // Should NOT call setText - prompt should be preserved regardless of content + expect(mockSetText).not.toHaveBeenCalled(); + + unmount(); }); it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => { @@ -3210,14 +3046,8 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => - expect(capturedUIState.userMessages).toContain('previous message'), - ); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState.userMessages).toContain('previous message'); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3227,11 +3057,9 @@ describe('AppContainer State Management', () => { onCancelSubmit(true); }); - await waitFor(() => { - expect(mockSetText).toHaveBeenCalledWith('previous message'); - }); + expect(mockSetText).toHaveBeenCalledWith('previous message'); - unmount!(); + unmount(); }); it('input history is independent from conversation history (survives /clear)', async () => { @@ -3244,18 +3072,10 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - let rerender: (tree: ReactElement) => void; - let unmount; - await act(async () => { - const result = renderAppContainer(); - rerender = result.rerender; - unmount = result.unmount; - }); + const { rerender, unmount } = await act(async () => renderAppContainer()); // Verify userMessages is populated from inputHistory - await waitFor(() => - expect(capturedUIState.userMessages).toContain('first prompt'), - ); + expect(capturedUIState.userMessages).toContain('first prompt'); expect(capturedUIState.userMessages).toContain('second prompt'); // Clear the conversation history (simulating /clear command) @@ -3278,7 +3098,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.userMessages).toContain('first prompt'); expect(capturedUIState.userMessages).toContain('second prompt'); - unmount!(); + unmount(); }); }); @@ -3293,14 +3113,10 @@ describe('AppContainer State Management', () => { // Clear previous calls mocks.mockStdout.write.mockClear(); - let compUnmount: () => void = () => {}; - await act(async () => { - const { unmount } = renderAppContainer(); - compUnmount = unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); // Allow async effects to run - await waitFor(() => expect(capturedUIState).toBeTruthy()); + expect(capturedUIState).toBeTruthy(); // Wait for fetchBannerTexts to complete await act(async () => { @@ -3313,7 +3129,7 @@ describe('AppContainer State Management', () => { ); expect(clearTerminalCalls).toHaveLength(0); - compUnmount(); + unmount(); }); }); @@ -3324,20 +3140,13 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue([]); - let unmount: () => void; - await act(async () => { - unmount = renderAppContainer({ - settings: { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { ...mockSettings.merged.ui, useAlternateBuffer: false }, - }, - } as LoadedSettings, - }).unmount; - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }), + ); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); @@ -3355,7 +3164,7 @@ describe('AppContainer State Management', () => { expect(mocks.mockStdout.write).toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); - unmount!(); + unmount(); }); it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { @@ -3364,20 +3173,15 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue([]); - let unmount: () => void; - await act(async () => { - unmount = renderAppContainer({ - settings: { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { ...mockSettings.merged.ui, useAlternateBuffer: true }, - }, - } as LoadedSettings, - }).unmount; - }); + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + const { unmount } = await act(async () => + renderAppContainer({ + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }), + ); + + expect(capturedUIActions).toBeTruthy(); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); @@ -3395,7 +3199,7 @@ describe('AppContainer State Management', () => { expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); - unmount!(); + unmount(); }); }); @@ -3408,13 +3212,9 @@ describe('AppContainer State Management', () => { vi.useRealTimers(); }); - it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + it('should set showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Trigger overflow act(() => { @@ -3440,18 +3240,65 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); + }); + + it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + + // 1. Trigger first overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id-1'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // 2. Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // 3. Trigger second overflow (this should reset the timer) + act(() => { + capturedOverflowActions.addOverflowingId('test-id-2'); + }); + + // Advance by 1ms to allow the OverflowProvider's 0ms batching timeout to fire + // and flush the state update to AppContainer, triggering the reset. + act(() => { + vi.advanceTimersByTime(1); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // 4. Advance enough that the ORIGINAL timer would have expired + // Subtracting 1ms since we advanced it above to flush the state. + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1); + }); + // The hint should STILL be visible because the timer reset at step 3 + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // 5. Advance to the end of the NEW timer + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount(); }); it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { - let unmount: () => void; - let stdin: ReturnType['stdin']; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - stdin = result.stdin; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { stdin, unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); @@ -3476,10 +3323,8 @@ describe('AppContainer State Management', () => { stdin.write('\x0f'); // \x0f is Ctrl+O }); - await waitFor(() => { - // constrainHeight should toggle - expect(capturedUIState.constrainHeight).toBe(false); - }); + // constrainHeight should toggle + expect(capturedUIState.constrainHeight).toBe(false); // Advance enough that the original timer would have expired if it hadn't reset act(() => { @@ -3498,18 +3343,12 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { - let unmount: () => void; - let stdin: ReturnType['stdin']; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - stdin = result.stdin; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { stdin, unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); @@ -3533,9 +3372,7 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - await waitFor(() => { - expect(capturedUIState.constrainHeight).toBe(false); - }); + expect(capturedUIState.constrainHeight).toBe(false); // Wait 1 second act(() => { @@ -3547,9 +3384,7 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - await waitFor(() => { - expect(capturedUIState.constrainHeight).toBe(true); - }); + expect(capturedUIState.constrainHeight).toBe(true); // Wait 1 second act(() => { @@ -3561,9 +3396,7 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - await waitFor(() => { - expect(capturedUIState.constrainHeight).toBe(false); - }); + expect(capturedUIState.constrainHeight).toBe(false); // Now we wait just before the timeout from the LAST toggle. // It should still be true. @@ -3581,39 +3414,34 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); - it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { - const alternateSettings = mergeSettings({}, {}, {}, {}, true); - const settingsWithAlternateBuffer = { - merged: { - ...alternateSettings, - ui: { - ...alternateSettings.ui, - useAlternateBuffer: true, - }, - }, - } as unknown as LoadedSettings; - - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ - settings: settingsWithAlternateBuffer, - }); - unmount = result.unmount; + it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { + const settingsWithAlternateBuffer = createMockSettings({ + ui: { useAlternateBuffer: true }, }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); + + const { unmount } = await act(async () => + renderAppContainer({ + settings: settingsWithAlternateBuffer, + }), + ); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Trigger overflow act(() => { capturedOverflowActions.addOverflowingId('test-id'); }); - // Should NOT show hint because we are in Alternate Buffer Mode - expect(capturedUIState.showIsExpandableHint).toBe(false); + // Should NOW show hint because we are in Alternate Buffer Mode + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); - unmount!(); + unmount(); }); }); @@ -3624,10 +3452,9 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); - let unmount: () => void; - await act(async () => (unmount = renderAppContainer().unmount)); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), @@ -3637,7 +3464,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([ '/test/file.txt', ]); - await act(async () => unmount!()); + unmount(); }); it.each([true, false])( @@ -3653,10 +3480,9 @@ describe('AppContainer State Management', () => { ); const { submitQuery } = mockedUseGeminiStream(); - let unmount: () => void; - await act(async () => (unmount = renderAppContainer().unmount)); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), @@ -3675,7 +3501,7 @@ describe('AppContainer State Management', () => { } expect(submitQuery).toHaveBeenCalledWith('read @file.txt'); expect(capturedUIState.permissionConfirmationRequest).toBeNull(); - await act(async () => unmount!()); + unmount(); }, ); }); @@ -3688,17 +3514,11 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(true); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(true); + unmount(); }); it('should NOT allow plan mode when disabled in config', async () => { @@ -3708,17 +3528,11 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); }); it('should NOT allow plan mode when streaming', async () => { @@ -3729,17 +3543,11 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); }); it('should NOT allow plan mode when a tool is awaiting confirmation', async () => { @@ -3760,17 +3568,11 @@ describe('AppContainer State Management', () => { ], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); }); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b16d10f3db..563b6a2d44 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -48,6 +48,7 @@ import { type IdeInfo, type IdeContext, type UserTierId, + type GeminiUserTier, type UserFeedbackPayload, type AgentDefinition, type ApprovalMode, @@ -77,12 +78,17 @@ import { SessionStartSource, SessionEndReason, generateSummary, + escapeShellArg, + getShellConfiguration, type ConsentRequestPayload, type AgentsDiscoveredPayload, ChangeAuthRequestedError, + ProjectIdRequiredError, CoreToolCallStatus, - generateSteeringAckMessage, buildUserSteeringHintPrompt, + logBillingEvent, + ApiKeyUpdatedEvent, + type InjectionSource, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -100,7 +106,7 @@ import { useOverflowActions, useOverflowState, } from './contexts/OverflowContext.js'; -import { useConsoleMessages } from './hooks/useConsoleMessages.js'; +import { useErrorCount } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; import { calculateMainAreaWidth } from './utils/ui-sizing.js'; @@ -117,7 +123,7 @@ import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { KeypressPriority } from './contexts/KeypressContext.js'; -import { keyMatchers, Command } from './keyMatchers.js'; +import { Command } from './key/keyMatchers.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; @@ -127,7 +133,7 @@ import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; -import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js'; +import { relaunchApp } from '../utils/processUtils.js'; import type { SessionInfo } from '../utils/sessionUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useMcpStatus } from './hooks/useMcpStatus.js'; @@ -144,9 +150,7 @@ import { requestConsentInteractive } from '../config/extensions/consent.js'; import { useSessionBrowser } from './hooks/useSessionBrowser.js'; import { useSessionResume } from './hooks/useSessionResume.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; -import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; -import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; @@ -162,9 +166,10 @@ import { import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; +import { parseSlashCommand } from '../utils/commands.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; -import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js'; +import { useIsHelpDismissKey } from './utils/shortcutsHelp.js'; import { useSuspend } from './hooks/useSuspend.js'; import { useRunEventNotifications } from './hooks/useRunEventNotifications.js'; import { isNotificationsEnabled } from '../utils/terminalNotifications.js'; @@ -205,6 +210,7 @@ import { useVisibilityToggle, APPROVAL_MODE_REVEAL_DURATION_MS, } from './hooks/useVisibilityToggle.js'; +import { useKeyMatchers } from './hooks/useKeyMatchers.js'; /** * The fraction of the terminal width to allocate to the shell. @@ -219,6 +225,8 @@ const SHELL_WIDTH_FRACTION = 0.89; const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { + const isHelpDismissKey = useIsHelpDismissKey(); + const keyMatchers = useKeyMatchers(); const { config, initializationResult, resumedSessionData } = props; const settings = useSettings(); const { reset } = useOverflowActions()!; @@ -229,7 +237,7 @@ export const AppContainer = (props: AppContainerProps) => { }); useMemoryMonitor(historyManager); - const isAlternateBuffer = useAlternateBuffer(); + const isAlternateBuffer = config.getUseAlternateBuffer(); const [corgiMode, setCorgiMode] = useState(false); const [forceRerenderKey, setForceRerenderKey] = useState(0); const [debugMessage, setDebugMessage] = useState(''); @@ -269,15 +277,17 @@ export const AppContainer = (props: AppContainerProps) => { () => isWorkspaceTrusted(settings.merged).isTrusted, ); - const [queueErrorMessage, setQueueErrorMessage] = useState( - null, + const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage( + QUEUE_ERROR_DISPLAY_DURATION_MS, ); const canResumeOnStartupRef = useRef(true); const [newAgents, setNewAgents] = useState(null); const [constrainHeight, setConstrainHeight] = useState(true); - const [showIsExpandableHint, setShowIsExpandableHint] = useState(false); - const expandHintTimerRef = useRef(null); + const [expandHintTrigger, triggerExpandHint] = useTimedMessage( + EXPAND_HINT_DURATION_MS, + ); + const showIsExpandableHint = Boolean(expandHintTrigger); const overflowState = useOverflowState(); const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; @@ -286,43 +296,18 @@ export const AppContainer = (props: AppContainerProps) => { * Manages the visibility and x-second timer for the expansion hint. * * This effect triggers the timer countdown whenever an overflow is detected - * or the user manually toggles the expansion state with Ctrl+O. We use a stable - * boolean dependency (hasOverflowState) to ensure the timer only resets on - * genuine state transitions, preventing it from infinitely resetting during - * active text streaming. + * or the user manually toggles the expansion state with Ctrl+O. + * By depending on overflowingIdsSize, the timer resets when *new* views + * overflow, but avoids infinitely resetting during single-view streaming. + * + * In alternate buffer mode, we don't trigger the hint automatically on overflow + * to avoid noise, but the user can still trigger it manually with Ctrl+O. */ useEffect(() => { - if (isAlternateBuffer) { - setShowIsExpandableHint(false); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - return; - } - if (hasOverflowState) { - setShowIsExpandableHint(true); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - expandHintTimerRef.current = setTimeout(() => { - setShowIsExpandableHint(false); - }, EXPAND_HINT_DURATION_MS); + triggerExpandHint(true); } - }, [hasOverflowState, isAlternateBuffer, constrainHeight]); - - /** - * Safe cleanup to ensure the expansion hint timer is cancelled when the - * component unmounts, preventing memory leaks. - */ - useEffect( - () => () => { - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - }, - [], - ); + }, [hasOverflowState, overflowingIdsSize, triggerExpandHint]); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); @@ -420,6 +405,9 @@ export const AppContainer = (props: AppContainerProps) => { ? { remaining, limit, resetTime } : undefined; }); + const [paidTier, setPaidTier] = useState( + undefined, + ); const [isConfigInitialized, setConfigInitialized] = useState(false); @@ -495,9 +483,11 @@ export const AppContainer = (props: AppContainerProps) => { disableMouseEvents(); // Kill all background shells - for (const pid of backgroundShellsRef.current.keys()) { - ShellExecutionService.kill(pid); - } + await Promise.all( + Array.from(backgroundShellsRef.current.keys()).map((pid) => + ShellExecutionService.kill(pid), + ), + ); const ideClient = await IdeClient.getInstance(); await ideClient.disconnect(); @@ -570,10 +560,9 @@ export const AppContainer = (props: AppContainerProps) => { }; }, [settings]); - const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } = - useConsoleMessages(); + const { errorCount, clearErrorCount } = useErrorCount(); - const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings); + const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config); // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = @@ -698,7 +687,14 @@ export const AppContainer = (props: AppContainerProps) => { onAuthError, apiKeyDefaultValue, reloadApiKey, - } = useAuthCommand(settings, config, initializationResult.authError); + accountSuspensionInfo, + setAccountSuspensionInfo, + } = useAuthCommand( + settings, + config, + initializationResult.authError, + initializationResult.accountSuspensionInfo, + ); const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>( {}, ); @@ -715,12 +711,20 @@ export const AppContainer = (props: AppContainerProps) => { handleProQuotaChoice, validationRequest, handleValidationChoice, + // G1 AI Credits + overageMenuRequest, + handleOverageMenuChoice, + emptyWalletRequest, + handleEmptyWalletChoice, } = useQuotaAndFallback({ config, historyManager, userTier, + paidTier, + settings, setModelSwitchedFromQuotaError, onShowAuthSelection: () => setAuthState(AuthState.Updating), + errorVerbosity: settings.merged.ui.errorVerbosity, }); // Derive auth state variables for backward compatibility with UIStateContext @@ -742,7 +746,8 @@ export const AppContainer = (props: AppContainerProps) => { originProjectPath: string; }) => new Promise((resolve) => { - const rerunCommand = `cd ${originProjectPath}\ngemini --resume ${sessionId}`; + const { shell } = getShellConfiguration(); + const rerunCommand = `cd ${escapeShellArg(originProjectPath, shell)}\ngemini --resume ${escapeShellArg(sessionId, shell)}`; const prompt = source === 'startup' ? `Session ${sessionId} was created in:\n\n${originProjectPath}\n\nYou are currently in:\n\n${currentProjectPath}\n\nDo you want to resume it here instead? Choose "No" to exit and rerun from the original folder:\n\n${rerunCommand}` @@ -799,6 +804,8 @@ export const AppContainer = (props: AppContainerProps) => { const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { if (authType) { + const previousAuthType = + config.getContentGeneratorConfig()?.authType ?? 'unknown'; if (authType === AuthType.LOGIN_WITH_GOOGLE) { setAuthContext({ requiresRestart: true }); } else { @@ -811,10 +818,20 @@ export const AppContainer = (props: AppContainerProps) => { config.setRemoteAdminSettings(undefined); await config.refreshAuth(authType); setAuthState(AuthState.Authenticated); + logBillingEvent( + config, + new ApiKeyUpdatedEvent(previousAuthType, authType), + ); } catch (e) { if (e instanceof ChangeAuthRequestedError) { return; } + if (e instanceof ProjectIdRequiredError) { + // OAuth succeeded but account setup requires project ID + // Show the error message directly without "Failed to authenticate" prefix + onAuthError(getErrorMessage(e)); + return; + } onAuthError( `Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`, ); @@ -825,13 +842,12 @@ export const AppContainer = (props: AppContainerProps) => { authType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { - await runExitCleanup(); writeToStdout(` ---------------------------------------------------------------- Logging in with Google... Restarting Gemini CLI to continue. ---------------------------------------------------------------- `); - process.exit(RELAUNCH_EXIT_CODE); + await relaunchApp(); } } setAuthState(AuthState.Authenticated); @@ -873,6 +889,7 @@ Logging in with Google... Restarting Gemini CLI to continue. // Only sync when not currently authenticating if (authState === AuthState.Authenticated) { setUserTier(config.getUserTier()); + setPaidTier(config.getUserPaidTier()); } }, [config, authState]); @@ -1040,18 +1057,26 @@ Logging in with Google... Restarting Gemini CLI to continue. Date.now(), ); try { - const { memoryContent, fileCount } = - await refreshServerHierarchicalMemory(config); + let flattenedMemory: string; + let fileCount: number; - const flattenedMemory = flattenMemory(memoryContent); + if (config.isJitContextEnabled()) { + await config.getContextManager()?.refresh(); + flattenedMemory = flattenMemory(config.getUserMemory()); + fileCount = config.getGeminiMdFileCount(); + } else { + const result = await refreshServerHierarchicalMemory(config); + flattenedMemory = flattenMemory(result.memoryContent); + fileCount = result.fileCount; + } historyManager.addItem( { type: MessageType.INFO, - text: `Memory refreshed successfully. ${ + text: `Memory reloaded successfully. ${ flattenedMemory.length > 0 - ? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).` - : 'No memory content found.' + ? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s)` + : 'No memory content found' }`, }, Date.now(), @@ -1122,13 +1147,16 @@ Logging in with Google... Restarting Gemini CLI to continue. }, []); useEffect(() => { - const hintListener = (hint: string) => { - pendingHintsRef.current.push(hint); + const hintListener = (text: string, source: InjectionSource) => { + if (source !== 'user_steering') { + return; + } + pendingHintsRef.current.push(text); setPendingHintCount((prev) => prev + 1); }; - config.userHintService.onUserHint(hintListener); + config.injectionService.onInjection(hintListener); return () => { - config.userHintService.offUserHint(hintListener); + config.injectionService.offInjection(hintListener); }; }, [config]); @@ -1256,8 +1284,15 @@ Logging in with Google... Restarting Gemini CLI to continue. return; } + // If cancelling (shouldRestorePrompt=false), never modify the buffer + // User is in control - preserve whatever text they typed, pasted, or restored + if (!shouldRestorePrompt) { + return; + } + + // Restore the last message when shouldRestorePrompt=true const lastUserMessage = inputHistory.at(-1); - let textToSet = shouldRestorePrompt ? lastUserMessage || '' : ''; + let textToSet = lastUserMessage || ''; const queuedText = getQueuedMessagesText(); if (queuedText) { @@ -1265,7 +1300,7 @@ Logging in with Google... Restarting Gemini CLI to continue. clearQueue(); } - if (textToSet || !shouldRestorePrompt) { + if (textToSet) { buffer.setText(textToSet); } }, @@ -1285,7 +1320,7 @@ Logging in with Google... Restarting Gemini CLI to continue. if (!trimmed) { return; } - config.userHintService.addUserHint(trimmed); + config.injectionService.addInjection(trimmed, 'user_steering'); // Render hints with a distinct style. historyManager.addItem({ type: 'hint', @@ -1299,10 +1334,7 @@ Logging in with Google... Restarting Gemini CLI to continue. async (submittedValue: string) => { reset(); // Explicitly hide the expansion hint and clear its x-second timer when a new turn begins. - setShowIsExpandableHint(false); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } + triggerExpandHint(null); if (!constrainHeight) { setConstrainHeight(true); if (!isAlternateBuffer) { @@ -1319,6 +1351,18 @@ Logging in with Google... Restarting Gemini CLI to continue. ...pendingGeminiHistoryItems, ]); + if (isSlash && isAgentRunning) { + const { commandToExecute } = parseSlashCommand( + submittedValue, + slashCommands ?? [], + ); + if (commandToExecute?.isSafeConcurrent) { + void handleSlashCommand(submittedValue); + addInput(submittedValue); + return; + } + } + if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) { handleHintSubmit(submittedValue); addInput(submittedValue); @@ -1362,6 +1406,8 @@ Logging in with Google... Restarting Gemini CLI to continue. addMessage, addInput, submitQuery, + handleSlashCommand, + slashCommands, isMcpReady, streamingState, messageQueue.length, @@ -1374,25 +1420,23 @@ Logging in with Google... Restarting Gemini CLI to continue. refreshStatic, reset, handleHintSubmit, + triggerExpandHint, ], ); const handleClearScreen = useCallback(() => { reset(); // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. - setShowIsExpandableHint(false); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } + triggerExpandHint(null); historyManager.clearItems(); - clearConsoleMessagesState(); + clearErrorCount(); refreshStatic(); }, [ historyManager, - clearConsoleMessagesState, + clearErrorCount, refreshStatic, reset, - setShowIsExpandableHint, + triggerExpandHint, ]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); @@ -1430,11 +1474,7 @@ Logging in with Google... Restarting Gemini CLI to continue. // Compute available terminal height based on controls measurement const availableTerminalHeight = Math.max( 0, - terminalHeight - - controlsHeight - - staticExtraHeight - - 2 - - backgroundShellHeight, + terminalHeight - controlsHeight - backgroundShellHeight - 1, ); config.setShellExecutionConfig({ @@ -1446,6 +1486,7 @@ Logging in with Google... Restarting Gemini CLI to continue. pager: settings.merged.tools.shell.pager, showColor: settings.merged.tools.shell.showColor, sanitizationConfig: config.sanitizationConfig, + sandboxManager: config.sandboxManager, }); const { isFocused, hasReceivedFocusEvent } = useFocus(); @@ -1568,28 +1609,6 @@ Logging in with Google... Restarting Gemini CLI to continue. useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); - const handleAutoEnableRetention = useCallback(() => { - const userSettings = settings.forScope(SettingScope.User).settings; - const currentRetention = userSettings.general?.sessionRetention ?? {}; - - settings.setValue(SettingScope.User, 'general.sessionRetention', { - ...currentRetention, - enabled: true, - maxAge: '30d', - warningAcknowledged: true, - }); - }, [settings]); - - const { - shouldShowWarning: shouldShowRetentionWarning, - checkComplete: retentionCheckComplete, - sessionsToDeleteCount, - } = useSessionRetentionCheck( - config, - settings.merged, - handleAutoEnableRetention, - ); - const tabFocusTimeoutRef = useRef(null); useEffect(() => { @@ -1652,17 +1671,6 @@ Logging in with Google... Restarting Gemini CLI to continue. } }, [ideNeedsRestart]); - useEffect(() => { - if (queueErrorMessage) { - const timer = setTimeout(() => { - setQueueErrorMessage(null); - }, QUEUE_ERROR_DISPLAY_DURATION_MS); - - return () => clearTimeout(timer); - } - return undefined; - }, [queueErrorMessage, setQueueErrorMessage]); - useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; @@ -1720,16 +1728,12 @@ Logging in with Google... Restarting Gemini CLI to continue. retryStatus, loadingPhrasesMode: settings.merged.ui.loadingPhrases, customWittyPhrases: settings.merged.ui.customWittyPhrases, + errorVerbosity: settings.merged.ui.errorVerbosity, }); const handleGlobalKeypress = useCallback( (key: Key): boolean => { - // Debug log keystrokes if enabled - if (settings.merged.general.debugKeystrokeLogging) { - debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); - } - - if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) { + if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } @@ -1768,13 +1772,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setConstrainHeight(true); if (keyMatchers[Command.SHOW_MORE_LINES](key)) { // If the user manually collapses the view, show the hint and reset the x-second timer. - setShowIsExpandableHint(true); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - expandHintTimerRef.current = setTimeout(() => { - setShowIsExpandableHint(false); - }, EXPAND_HINT_DURATION_MS); + triggerExpandHint(true); } if (!isAlternateBuffer) { refreshStatic(); @@ -1823,13 +1821,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ) { setConstrainHeight(false); // If the user manually expands the view, show the hint and reset the x-second timer. - setShowIsExpandableHint(true); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - expandHintTimerRef.current = setTimeout(() => { - setShowIsExpandableHint(false); - }, EXPAND_HINT_DURATION_MS); + triggerExpandHint(true); if (!isAlternateBuffer) { refreshStatic(); } @@ -1919,7 +1911,6 @@ Logging in with Google... Restarting Gemini CLI to continue. activePtyId, handleSuspend, embeddedShellFocused, - settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, tabFocusTimeoutRef, @@ -1934,13 +1925,27 @@ Logging in with Google... Restarting Gemini CLI to continue. showTransientMessage, settings.merged.general.devtools, showErrorDetails, + triggerExpandHint, + keyMatchers, + isHelpDismissKey, ], ); useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); useKeypress( - () => { + (key: Key) => { + if ( + keyMatchers[Command.SCROLL_UP](key) || + keyMatchers[Command.SCROLL_DOWN](key) || + keyMatchers[Command.PAGE_UP](key) || + keyMatchers[Command.PAGE_DOWN](key) || + keyMatchers[Command.SCROLL_HOME](key) || + keyMatchers[Command.SCROLL_END](key) + ) { + return false; + } + setCopyModeEnabled(false); enableMouseEvents(); return true; @@ -2034,27 +2039,10 @@ Logging in with Google... Restarting Gemini CLI to continue. }; }, [historyManager]); - const filteredConsoleMessages = useMemo(() => { - if (config.getDebugMode()) { - return consoleMessages; - } - return consoleMessages.filter((msg) => msg.type !== 'debug'); - }, [consoleMessages, config]); - - // Computed values - const errorCount = useMemo( - () => - filteredConsoleMessages - .filter((msg) => msg.type === 'error') - .reduce((total, msg) => total + msg.count, 0), - [filteredConsoleMessages], - ); - const nightly = props.version.includes('nightly'); const hasPendingIncludeDirectories = config.getPendingIncludeDirectories().length > 0; const hasStartupBlockingDialog = - (shouldShowRetentionWarning && retentionCheckComplete) || adminSettingsChanged || showIdeRestartPrompt || !!proQuotaRequest || @@ -2119,7 +2107,6 @@ Logging in with Google... Restarting Gemini CLI to continue. ]); const dialogsVisible = - (shouldShowRetentionWarning && retentionCheckComplete) || visibleShouldShowIdePrompt || isFolderTrustDialogOpen || isPolicyUpdateDialogOpen || @@ -2143,6 +2130,8 @@ Logging in with Google... Restarting Gemini CLI to continue. showIdeRestartPrompt || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || isSessionBrowserOpen || authState === AuthState.AwaitingApiKeyInput || !!visibleNewAgents; @@ -2166,6 +2155,8 @@ Logging in with Google... Restarting Gemini CLI to continue. hasLoopDetectionConfirmationRequest || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || !!customDialog; const allowPlanMode = @@ -2226,15 +2217,6 @@ Logging in with Google... Restarting Gemini CLI to continue. return; } - void generateSteeringAckMessage( - config.getBaseLlmClient(), - pendingHint, - ).then((ackText) => { - historyManager.addItem({ - type: 'info', - text: ackText, - }); - }); void submitQuery([{ text: buildUserSteeringHintPrompt(pendingHint) }]); }, [ config, @@ -2299,13 +2281,12 @@ Logging in with Google... Restarting Gemini CLI to continue. history: historyManager.history, historyManager, isThemeDialogOpen, - shouldShowRetentionWarning: - shouldShowRetentionWarning && retentionCheckComplete, - sessionsToDeleteCount: sessionsToDeleteCount ?? 0, + themeError, isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, apiKeyDefaultValue, @@ -2354,7 +2335,6 @@ Logging in with Google... Restarting Gemini CLI to continue. constrainHeight, showErrorDetails, showFullTodos, - filteredConsoleMessages, ideContextState, renderMarkdown, ctrlCPressedOnce: ctrlCPressCount >= 1, @@ -2377,6 +2357,9 @@ Logging in with Google... Restarting Gemini CLI to continue. stats: quotaStats, proQuotaRequest, validationRequest, + // G1 AI Credits dialog state + overageMenuRequest, + emptyWalletRequest, }, contextFileNames, errorCount, @@ -2428,13 +2411,12 @@ Logging in with Google... Restarting Gemini CLI to continue. }), [ isThemeDialogOpen, - shouldShowRetentionWarning, - retentionCheckComplete, - sessionsToDeleteCount, + themeError, isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, editorError, isEditorDialogOpen, @@ -2481,7 +2463,6 @@ Logging in with Google... Restarting Gemini CLI to continue. constrainHeight, showErrorDetails, showFullTodos, - filteredConsoleMessages, ideContextState, renderMarkdown, ctrlCPressCount, @@ -2502,6 +2483,8 @@ Logging in with Google... Restarting Gemini CLI to continue. quotaStats, proQuotaRequest, validationRequest, + overageMenuRequest, + emptyWalletRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -2583,6 +2566,9 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + // G1 AI Credits handlers + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -2616,8 +2602,7 @@ Logging in with Google... Restarting Gemini CLI to continue. }); } } - await runExitCleanup(); - process.exit(RELAUNCH_EXIT_CODE); + await relaunchApp(); }, handleNewAgentsSelect: async (choice: NewAgentsChoice) => { if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) { @@ -2640,6 +2625,10 @@ Logging in with Google... Restarting Gemini CLI to continue. setNewAgents(null); }, getPreferredEditor, + clearAccountSuspension: () => { + setAccountSuspensionInfo(null); + setAuthState(AuthState.Updating); + }, }), [ handleThemeSelect, @@ -2669,6 +2658,8 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -2688,6 +2679,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setActiveBackgroundShellPid, setIsBackgroundShellListOpen, setAuthContext, + setAccountSuspensionInfo, newAgents, config, historyManager, diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx index 52d00550ea..d05a17dad8 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -5,10 +5,9 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import { render } from '../test-utils/render.js'; +import { renderWithProviders } from '../test-utils/render.js'; import { act } from 'react'; import { IdeIntegrationNudge } from './IdeIntegrationNudge.js'; -import { KeypressProvider } from './contexts/KeypressContext.js'; import { debugLogger } from '@google/gemini-cli-core'; // Mock debugLogger @@ -43,6 +42,7 @@ describe('IdeIntegrationNudge', () => { beforeEach(() => { vi.mocked(debugLogger.warn).mockImplementation((...args) => { if ( + // eslint-disable-next-line no-restricted-syntax typeof args[0] === 'string' && /was not wrapped in act/.test(args[0]) ) { @@ -54,12 +54,9 @@ describe('IdeIntegrationNudge', () => { }); it('renders correctly with default options', async () => { - const { lastFrame, waitUntilReady, unmount } = render( - - - , + const { lastFrame, unmount } = await renderWithProviders( + , ); - await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?'); @@ -71,14 +68,10 @@ describe('IdeIntegrationNudge', () => { it('handles "Yes" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , ); - await waitUntilReady(); - // "Yes" is the first option and selected by default usually. await act(async () => { stdin.write('\r'); @@ -94,14 +87,10 @@ describe('IdeIntegrationNudge', () => { it('handles "No" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , ); - await waitUntilReady(); - // Navigate down to "No (esc)" await act(async () => { stdin.write('\u001B[B'); // Down arrow @@ -122,14 +111,10 @@ describe('IdeIntegrationNudge', () => { it('handles "Dismiss" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , ); - await waitUntilReady(); - // Navigate down to "No, don't ask again" await act(async () => { stdin.write('\u001B[B'); // Down arrow @@ -155,14 +140,10 @@ describe('IdeIntegrationNudge', () => { it('handles Escape key press', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = await renderWithProviders( + , ); - await waitUntilReady(); - // Press Escape await act(async () => { stdin.write('\u001B'); @@ -184,13 +165,10 @@ describe('IdeIntegrationNudge', () => { vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp'); const onComplete = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = render( - - - , - ); - - await waitUntilReady(); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderWithProviders( + , + ); const frame = lastFrame(); diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx index 409a6469f6..37823cf8a8 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -6,8 +6,10 @@ import type { IdeInfo } from '@google/gemini-cli-core'; import { Box, Text } from 'ink'; -import type { RadioSelectItem } from './components/shared/RadioButtonSelect.js'; -import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js'; +import { + RadioButtonSelect, + type RadioSelectItem, +} from './components/shared/RadioButtonSelect.js'; import { useKeypress } from './hooks/useKeypress.js'; import { theme } from './semantic-colors.js'; diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 450da8362e..9e1d66df01 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -2,20 +2,20 @@ exports[`App > Snapshots > renders default layout correctly 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results + + + @@ -47,34 +47,31 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` "Notifications Footer - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results Composer " `; exports[`App > Snapshots > renders with dialogs visible 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + + + + @@ -110,20 +107,17 @@ DialogManager exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results HistoryItemDisplay ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Action Required │ @@ -146,6 +140,9 @@ HistoryItemDisplay + + + Notifications Composer " diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index 86d3204b84..d46e0295a1 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -29,9 +29,16 @@ vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); -vi.mock('../components/shared/text-buffer.js', () => ({ - useTextBuffer: vi.fn(), -})); +vi.mock('../components/shared/text-buffer.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../components/shared/text-buffer.js') + >(); + return { + ...actual, + useTextBuffer: vi.fn(), + }; +}); vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: vi.fn(() => ({ @@ -66,23 +73,21 @@ describe('ApiAuthDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders with a defaultValue', async () => { - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); expect(mockedUseTextBuffer).toHaveBeenCalledWith( expect.objectContaining({ initialText: 'test-key', @@ -96,7 +101,7 @@ describe('ApiAuthDialog', () => { it.each([ { - keyName: 'return', + keyName: 'enter', sequence: '\r', expectedCall: onSubmit, args: ['submitted-key'], @@ -106,10 +111,9 @@ describe('ApiAuthDialog', () => { 'calls $expectedCall.name when $keyName is pressed', async ({ keyName, sequence, expectedCall, args }) => { mockBuffer.text = 'submitted-key'; // Set for the onSubmit case - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) // calls[1] is the TextInput's useKeypress (typing handler) const keypressHandler = mockedUseKeypress.mock.calls[1][0]; @@ -129,24 +133,22 @@ describe('ApiAuthDialog', () => { ); it('displays an error message', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Invalid API Key'); unmount(); }); it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); // Call 0 is ApiAuthDialog (isActive: true) // Call 1 is TextInput (isActive: true, priority: true) const keypressHandler = mockedUseKeypress.mock.calls[0][0]; diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx index c5ac742955..b96a9ece57 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx @@ -13,7 +13,8 @@ import { useTextBuffer } from '../components/shared/text-buffer.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { clearApiKey, debugLogger } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; +import { Command } from '../key/keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface ApiAuthDialogProps { onSubmit: (apiKey: string) => void; @@ -28,6 +29,7 @@ export function ApiAuthDialog({ error, defaultValue = '', }: ApiAuthDialogProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); const { terminalWidth } = useUIState(); const viewportWidth = terminalWidth - 8; @@ -98,7 +100,7 @@ export function ApiAuthDialog({ return ( { for (const [key, value] of Object.entries(env)) { vi.stubEnv(key, value as string); } - const { waitUntilReady, unmount } = renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; for (const item of shouldContain) { expect(items).toContainEqual(item); @@ -161,10 +160,7 @@ describe('AuthDialog', () => { it('filters auth types when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(1); expect(items[0].value).toBe(AuthType.USE_GEMINI); @@ -173,10 +169,7 @@ describe('AuthDialog', () => { it('sets initial index to 0 when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(initialIndex).toBe(0); unmount(); @@ -209,14 +202,11 @@ describe('AuthDialog', () => { { setup: () => {}, expected: AuthType.LOGIN_WITH_GOOGLE, - desc: 'defaults to Login with Google', + desc: 'defaults to Sign in with Google', }, ])('selects initial auth type $desc', async ({ setup, expected }) => { setup(); - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(items[initialIndex].value).toBe(expected); unmount(); @@ -226,10 +216,7 @@ describe('AuthDialog', () => { describe('handleAuthSelect', () => { it('calls onAuthError if validation fails', async () => { mockedValidateAuthMethod.mockReturnValue('Invalid method'); - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; handleAuthSelect(AuthType.USE_GEMINI); @@ -245,10 +232,7 @@ describe('AuthDialog', () => { it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); @@ -261,10 +245,7 @@ describe('AuthDialog', () => { it('sets auth context with empty object for other auth types', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -278,10 +259,7 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -297,10 +275,7 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', ''); // Empty string // props.settings.merged.security.auth.selectedType is undefined here - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -316,10 +291,7 @@ describe('AuthDialog', () => { // process.env['GEMINI_API_KEY'] is not set // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -337,10 +309,7 @@ describe('AuthDialog', () => { props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -351,7 +320,7 @@ describe('AuthDialog', () => { unmount(); }); - it('exits process for Login with Google when browser is suppressed', async () => { + it('exits process for Sign in with Google when browser is suppressed', async () => { vi.useFakeTimers(); const exitSpy = vi .spyOn(process, 'exit') @@ -360,10 +329,7 @@ describe('AuthDialog', () => { vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await act(async () => { @@ -383,10 +349,9 @@ describe('AuthDialog', () => { it('displays authError when provided', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Something went wrong'); unmount(); }); @@ -429,10 +394,7 @@ describe('AuthDialog', () => { }, ])('$desc', async ({ setup, expectations }) => { setup(); - const { waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ name: 'escape' }); expectations(props); @@ -442,30 +404,27 @@ describe('AuthDialog', () => { describe('Snapshots', () => { it('renders correctly with default props', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with auth error', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with enforced auth type', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 33652297b6..c823f606c6 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -9,11 +9,11 @@ import { useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; -import type { - LoadableSettingScope, - LoadedSettings, +import { + SettingScope, + type LoadableSettingScope, + type LoadedSettings, } from '../../config/settings.js'; -import { SettingScope } from '../../config/settings.js'; import { AuthType, clearCachedCredentialFile, @@ -21,9 +21,8 @@ import { } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { AuthState } from '../types.js'; -import { runExitCleanup } from '../../utils/cleanup.js'; import { validateAuthMethodWithSettings } from './useAuth.js'; -import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; +import { relaunchApp } from '../../utils/processUtils.js'; interface AuthDialogProps { config: Config; @@ -45,7 +44,7 @@ export function AuthDialog({ const [exiting, setExiting] = useState(false); let items = [ { - label: 'Login with Google', + label: 'Sign in with Google', value: AuthType.LOGIN_WITH_GOOGLE, key: AuthType.LOGIN_WITH_GOOGLE, }, @@ -133,10 +132,7 @@ export function AuthDialog({ config.isBrowserLaunchSuppressed() ) { setExiting(true); - setTimeout(async () => { - await runExitCleanup(); - process.exit(RELAUNCH_EXIT_CODE); - }, 100); + setTimeout(relaunchApp, 100); return; } @@ -193,7 +189,7 @@ export function AuthDialog({ return ( { vi.useFakeTimers(); vi.mocked(debugLogger.error).mockImplementation((...args) => { if ( + // eslint-disable-next-line no-restricted-syntax typeof args[0] === 'string' && args[0].includes('was not wrapped in act') ) { @@ -55,20 +56,18 @@ describe('AuthInProgress', () => { }); it('renders initial state with spinner', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); - expect(lastFrame()).toContain('[Spinner] Waiting for auth...'); - expect(lastFrame()).toContain('Press ESC or CTRL+C to cancel'); + expect(lastFrame()).toContain('[Spinner] Waiting for authentication...'); + expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel'); unmount(); }); it('calls onTimeout when ESC is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { @@ -84,10 +83,9 @@ describe('AuthInProgress', () => { }); it('calls onTimeout when Ctrl+C is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { @@ -100,10 +98,9 @@ describe('AuthInProgress', () => { }); it('calls onTimeout and shows timeout message after 3 minutes', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); await act(async () => { vi.advanceTimersByTime(180000); @@ -116,10 +113,7 @@ describe('AuthInProgress', () => { }); it('clears timer on unmount', async () => { - const { waitUntilReady, unmount } = render( - , - ); - await waitUntilReady(); + const { unmount } = await render(); await act(async () => { unmount(); diff --git a/packages/cli/src/ui/auth/AuthInProgress.tsx b/packages/cli/src/ui/auth/AuthInProgress.tsx index f5c5d7db6e..03d609c444 100644 --- a/packages/cli/src/ui/auth/AuthInProgress.tsx +++ b/packages/cli/src/ui/auth/AuthInProgress.tsx @@ -53,8 +53,8 @@ export function AuthInProgress({ ) : ( - Waiting for auth... (Press ESC or CTRL+C - to cancel) + Waiting for authentication... (Press Esc + or Ctrl+C to cancel) )} diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx new file mode 100644 index 0000000000..4b5d44e6d5 --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { BannedAccountDialog } from './BannedAccountDialog.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { Text } from 'ink'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + openBrowserSecurely: vi.fn(), + shouldLaunchBrowser: vi.fn().mockReturnValue(true), + }; +}); + +vi.mock('../../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: vi.fn(({ items }) => ( + <> + {items.map((item: { value: string; label: string }) => ( + {item.label} + ))} + + )), +})); + +const mockedRadioButtonSelect = RadioButtonSelect as Mock; +const mockedUseKeypress = useKeypress as Mock; +const mockedOpenBrowser = openBrowserSecurely as Mock; +const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock; +const mockedRunExitCleanup = runExitCleanup as Mock; + +const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = { + message: + 'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', +}; + +describe('BannedAccountDialog', () => { + let onExit: Mock; + let onChangeAuth: Mock; + + beforeEach(() => { + vi.resetAllMocks(); + mockedShouldLaunchBrowser.mockReturnValue(true); + mockedOpenBrowser.mockResolvedValue(undefined); + mockedRunExitCleanup.mockResolvedValue(undefined); + onExit = vi.fn(); + onChangeAuth = vi.fn(); + }); + + it('renders the suspension message from accountSuspensionInfo', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const frame = lastFrame(); + expect(frame).toContain('Account Suspended'); + expect(frame).toContain('violation of Terms of Service'); + expect(frame).toContain('Escape to exit'); + unmount(); + }); + + it('renders menu options with appeal link text from response', async () => { + const { unmount } = await renderWithProviders( + , + ); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(3); + expect(items[0].label).toBe('Appeal Here'); + expect(items[1].label).toBe('Change authentication'); + expect(items[2].label).toBe('Exit'); + unmount(); + }); + + it('hides form option when no appealUrl is provided', async () => { + const infoWithoutUrl: AccountSuspensionInfo = { + message: 'Account suspended.', + }; + const { unmount } = await renderWithProviders( + , + ); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(2); + expect(items[0].label).toBe('Change authentication'); + expect(items[1].label).toBe('Exit'); + unmount(); + }); + + it('uses default label when appealLinkText is not provided', async () => { + const infoWithoutLinkText: AccountSuspensionInfo = { + message: 'Account suspended.', + appealUrl: 'https://example.com/appeal', + }; + const { unmount } = await renderWithProviders( + , + ); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items[0].label).toBe('Open the Google Form'); + unmount(); + }); + + it('opens browser when appeal option is selected', async () => { + const { unmount } = await renderWithProviders( + , + ); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('open_form'); + expect(mockedOpenBrowser).toHaveBeenCalledWith( + 'https://example.com/appeal', + ); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('shows URL when browser cannot be launched', async () => { + mockedShouldLaunchBrowser.mockReturnValue(false); + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('open_form'); + await waitFor(() => { + expect(lastFrame()).toContain('Please open this URL in a browser'); + }); + expect(mockedOpenBrowser).not.toHaveBeenCalled(); + unmount(); + }); + + it('calls onExit when "Exit" is selected', async () => { + const { unmount } = await renderWithProviders( + , + ); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('exit'); + expect(mockedRunExitCleanup).toHaveBeenCalled(); + expect(onExit).toHaveBeenCalled(); + unmount(); + }); + + it('calls onChangeAuth when "Change authentication" is selected', async () => { + const { unmount } = await renderWithProviders( + , + ); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('change_auth'); + expect(onChangeAuth).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('exits on escape key', async () => { + const { unmount } = await renderWithProviders( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + const result = keypressHandler({ name: 'escape' }); + expect(result).toBe(true); + unmount(); + }); + + it('renders snapshot correctly', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.tsx new file mode 100644 index 0000000000..e051ba082b --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.tsx @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +interface BannedAccountDialogProps { + accountSuspensionInfo: AccountSuspensionInfo; + onExit: () => void; + onChangeAuth: () => void; +} + +export function BannedAccountDialog({ + accountSuspensionInfo, + onExit, + onChangeAuth, +}: BannedAccountDialogProps): React.JSX.Element { + const [errorMessage, setErrorMessage] = useState(null); + + const appealUrl = accountSuspensionInfo.appealUrl; + const appealLinkText = + accountSuspensionInfo.appealLinkText ?? 'Open the Google Form'; + + const items = useMemo(() => { + const menuItems = []; + if (appealUrl) { + menuItems.push({ + label: appealLinkText, + value: 'open_form' as const, + key: 'open_form', + }); + } + menuItems.push( + { + label: 'Change authentication', + value: 'change_auth' as const, + key: 'change_auth', + }, + { + label: 'Exit', + value: 'exit' as const, + key: 'exit', + }, + ); + return menuItems; + }, [appealUrl, appealLinkText]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + void handleExit(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const handleExit = useCallback(async () => { + await runExitCleanup(); + onExit(); + }, [onExit]); + + const handleSelect = useCallback( + async (choice: string) => { + if (choice === 'open_form' && appealUrl) { + if (!shouldLaunchBrowser()) { + setErrorMessage(`Please open this URL in a browser: ${appealUrl}`); + return; + } + + try { + await openBrowserSecurely(appealUrl); + } catch { + setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`); + } + } else if (choice === 'change_auth') { + onChangeAuth(); + } else { + await handleExit(); + } + }, + [handleExit, onChangeAuth, appealUrl], + ); + + return ( + + + Error: Account Suspended + + + + {accountSuspensionInfo.message} + + + {appealUrl && ( + <> + + Appeal URL: + + + [{appealUrl}] + + + )} + + {errorMessage && ( + + {errorMessage} + + )} + + + void handleSelect(choice)} + /> + + + + Escape to exit + + + ); +} diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx index 9079358348..4dd13a3334 100644 --- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx @@ -9,7 +9,10 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { runExitCleanup } from '../../utils/cleanup.js'; -import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; +import { + RELAUNCH_EXIT_CODE, + _resetRelaunchStateForTesting, +} from '../../utils/processUtils.js'; import { type Config } from '@google/gemini-cli-core'; // Mocks @@ -38,28 +41,27 @@ describe('LoginWithGoogleRestartDialog', () => { vi.clearAllMocks(); exitSpy.mockClear(); vi.useRealTimers(); + _resetRelaunchStateForTesting(); }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('calls onDismiss when escape is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ @@ -79,13 +81,12 @@ describe('LoginWithGoogleRestartDialog', () => { async (keyName) => { vi.useFakeTimers(); - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx index 86cd645fee..a781828d09 100644 --- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx @@ -8,8 +8,7 @@ import { type Config } from '@google/gemini-cli-core'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { runExitCleanup } from '../../utils/cleanup.js'; -import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; +import { relaunchApp } from '../../utils/processUtils.js'; interface LoginWithGoogleRestartDialogProps { onDismiss: () => void; @@ -36,8 +35,7 @@ export const LoginWithGoogleRestartDialog = ({ }); } } - await runExitCleanup(); - process.exit(RELAUNCH_EXIT_CODE); + await relaunchApp(); }, 100); return true; } @@ -47,13 +45,13 @@ export const LoginWithGoogleRestartDialog = ({ ); const message = - 'You have successfully logged in with Google. Gemini CLI needs to be restarted.'; + "You've successfully signed in with Google. Gemini CLI needs to be restarted."; return ( - {message} Press 'r' to restart, or 'escape' to - choose a different auth method. + {message} Press R to restart, or Esc to choose a different + authentication method. ); diff --git a/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap index 2d341c405e..05bc9f422e 100644 --- a/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap +++ b/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap @@ -7,7 +7,7 @@ exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = ` │ │ │ How would you like to authenticate for this project? │ │ │ -│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ +│ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ │ │ │ Something went wrong │ │ │ @@ -28,7 +28,7 @@ exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = ` │ │ │ How would you like to authenticate for this project? │ │ │ -│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ +│ (selected) Sign in with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ │ │ │ (Use Enter to select) │ │ │ diff --git a/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap new file mode 100644 index 0000000000..b95994692d --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`BannedAccountDialog > renders snapshot correctly 1`] = ` +" + Error: Account Suspended + + This service has been disabled in this account for violation of Terms of Service. Please submit an + appeal to continue using this product. + + Appeal URL: + [https://example.com/appeal] + + Appeal HereChange authenticationExit + + Escape to exit +" +`; diff --git a/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap index 20fad6d488..7c7a95e24f 100644 --- a/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap +++ b/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap @@ -2,8 +2,8 @@ exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ You have successfully logged in with Google. Gemini CLI needs to be restarted. Press 'r' to │ -│ restart, or 'escape' to choose a different auth method. │ +│ You've successfully signed in with Google. Gemini CLI needs to be restarted. Press R to restart, │ +│ or Esc to choose a different authentication method. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; diff --git a/packages/cli/src/ui/auth/useAuth.test.tsx b/packages/cli/src/ui/auth/useAuth.test.tsx index 36d9aeec4f..8d51e46a64 100644 --- a/packages/cli/src/ui/auth/useAuth.test.tsx +++ b/packages/cli/src/ui/auth/useAuth.test.tsx @@ -4,21 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mock, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js'; -import { AuthType, type Config } from '@google/gemini-cli-core'; +import { + AuthType, + type Config, + ProjectIdRequiredError, +} from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; -import { waitFor } from '../../test-utils/async.js'; // Mock dependencies const mockLoadApiKey = vi.fn(); @@ -138,155 +134,202 @@ describe('useAuth', () => { }, }) as LoadedSettings; + let deferredRefreshAuth: { + resolve: () => void; + reject: (e: Error) => void; + }; + + beforeEach(() => { + vi.mocked(mockConfig.refreshAuth).mockImplementation( + () => + new Promise((resolve, reject) => { + deferredRefreshAuth = { resolve, reject }; + }), + ); + }); + it('should initialize with Unauthenticated state', async () => { - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); + // Because we defer refreshAuth, the initial state is safely caught here expect(result.current.authState).toBe(AuthState.Unauthenticated); - await waitFor(() => { - expect(result.current.authState).toBe(AuthState.Authenticated); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(result.current.authState).toBe(AuthState.Authenticated); }); it('should set error if no auth type is selected and no env key', async () => { - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toBe( - 'No authentication method selected.', - ); - expect(result.current.authState).toBe(AuthState.Updating); - }); + // This happens synchronously, no deferred promise + expect(result.current.authError).toBe( + 'No authentication method selected.', + ); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should set error if no auth type is selected but env key exists', async () => { process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toContain( - 'Existing API key detected (GEMINI_API_KEY)', - ); - expect(result.current.authState).toBe(AuthState.Updating); - }); + expect(result.current.authError).toContain( + 'Existing API key detected (GEMINI_API_KEY)', + ); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => { - mockLoadApiKey.mockResolvedValue(null); - const { result } = renderHook(() => + let deferredLoadKey: { resolve: (k: string | null) => void }; + mockLoadApiKey.mockImplementation( + () => + new Promise((resolve) => { + deferredLoadKey = { resolve }; + }), + ); + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); + await act(async () => { + deferredLoadKey.resolve(null); }); + + expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); }); it('should authenticate if USE_GEMINI and key is found', async () => { - mockLoadApiKey.mockResolvedValue('stored-key'); - const { result } = renderHook(() => + let deferredLoadKey: { resolve: (k: string | null) => void }; + mockLoadApiKey.mockImplementation( + () => + new Promise((resolve) => { + deferredLoadKey = { resolve }; + }), + ); + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.USE_GEMINI, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.apiKeyDefaultValue).toBe('stored-key'); + await act(async () => { + deferredLoadKey.resolve('stored-key'); }); + + await act(async () => { + deferredRefreshAuth.resolve(); + }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('stored-key'); }); it('should authenticate if USE_GEMINI and env key is found', async () => { - mockLoadApiKey.mockResolvedValue(null); process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = renderHook(() => + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.USE_GEMINI, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.apiKeyDefaultValue).toBe('env-key'); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); it('should prioritize env key over stored key when both are present', async () => { - mockLoadApiKey.mockResolvedValue('stored-key'); process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = renderHook(() => + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.USE_GEMINI, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - // The environment key should take precedence - expect(result.current.apiKeyDefaultValue).toBe('env-key'); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); it('should set error if validation fails', async () => { mockValidateAuthMethod.mockReturnValue('Validation Failed'); - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toBe('Validation Failed'); - expect(result.current.authState).toBe(AuthState.Updating); - }); + expect(result.current.authError).toBe('Validation Failed'); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => { process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE'; - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toContain( - 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', - ); - expect(result.current.authState).toBe(AuthState.Updating); - }); + expect(result.current.authError).toContain( + 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', + ); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should authenticate successfully for valid auth type', async () => { - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.LOGIN_WITH_GOOGLE, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.authError).toBeNull(); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.authError).toBeNull(); }); it('should handle refreshAuth failure', async () => { - (mockConfig.refreshAuth as Mock).mockRejectedValue( - new Error('Auth Failed'), - ); - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toContain('Failed to login'); - expect(result.current.authState).toBe(AuthState.Updating); + await act(async () => { + deferredRefreshAuth.reject(new Error('Auth Failed')); }); + + expect(result.current.authError).toContain('Failed to sign in'); + expect(result.current.authState).toBe(AuthState.Updating); + }); + + it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => { + const projectIdError = new ProjectIdRequiredError(); + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), + ); + + await act(async () => { + deferredRefreshAuth.reject(projectIdError); + }); + + expect(result.current.authError).toBe( + 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', + ); + expect(result.current.authError).not.toContain('Failed to login'); + expect(result.current.authState).toBe(AuthState.Updating); }); }); }); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index effb17cdff..809a3b34b8 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -11,6 +11,8 @@ import { type Config, loadApiKey, debugLogger, + isAccountSuspendedError, + ProjectIdRequiredError, } from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; @@ -34,16 +36,21 @@ export function validateAuthMethodWithSettings( return validateAuthMethod(authType); } +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + export const useAuthCommand = ( settings: LoadedSettings, config: Config, initialAuthError: string | null = null, + initialAccountSuspensionInfo: AccountSuspensionInfo | null = null, ) => { const [authState, setAuthState] = useState( initialAuthError ? AuthState.Updating : AuthState.Unauthenticated, ); const [authError, setAuthError] = useState(initialAuthError); + const [accountSuspensionInfo, setAccountSuspensionInfo] = + useState(initialAccountSuspensionInfo); const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< string | undefined >(undefined); @@ -130,7 +137,20 @@ export const useAuthCommand = ( setAuthError(null); setAuthState(AuthState.Authenticated); } catch (e) { - onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + setAccountSuspensionInfo({ + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }); + } else if (e instanceof ProjectIdRequiredError) { + // OAuth succeeded but account setup requires project ID + // Show the error message directly without "Failed to login" prefix + onAuthError(getErrorMessage(e)); + } else { + onAuthError(`Failed to sign in. Message: ${getErrorMessage(e)}`); + } } })(); }, [ @@ -150,5 +170,7 @@ export const useAuthCommand = ( onAuthError, apiKeyDefaultValue, reloadApiKey, + accountSuspensionInfo, + setAccountSuspensionInfo, }; }; diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index f1c010678e..0fa1f709ba 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -36,10 +36,12 @@ describe('aboutCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - config: { - getModel: vi.fn(), - getIdeMode: vi.fn().mockReturnValue(true), - getUserTierName: vi.fn().mockReturnValue(undefined), + agentContext: { + config: { + getModel: vi.fn(), + getIdeMode: vi.fn().mockReturnValue(true), + getUserTierName: vi.fn().mockReturnValue(undefined), + }, }, settings: { merged: { @@ -57,9 +59,10 @@ describe('aboutCommand', () => { } as unknown as CommandContext); vi.mocked(getVersion).mockResolvedValue('test-version'); - vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue( - 'test-model', - ); + vi.spyOn( + mockContext.services.agentContext!.config, + 'getModel', + ).mockReturnValue('test-model'); process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project'; Object.defineProperty(process, 'platform', { value: 'test-os', @@ -160,9 +163,9 @@ describe('aboutCommand', () => { }); it('should display the tier when getUserTierName returns a value', async () => { - vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue( - 'Enterprise Tier', - ); + vi.mocked( + mockContext.services.agentContext!.config.getUserTierName, + ).mockReturnValue('Enterprise Tier'); if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index cf21d9b0d5..8b436d69b8 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { CommandContext, SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { + CommandKind, + type CommandContext, + type SlashCommand, +} from './types.js'; import process from 'node:process'; import { MessageType, type HistoryItemAbout } from '../types.js'; import { @@ -20,6 +23,7 @@ export const aboutCommand: SlashCommand = { description: 'Show version info', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: async (context) => { const osVersion = process.platform; let sandboxEnv = 'no sandbox'; @@ -30,7 +34,8 @@ export const aboutCommand: SlashCommand = { process.env['SEATBELT_PROFILE'] || 'unknown' })`; } - const modelVersion = context.services.config?.getModel() || 'Unknown'; + const modelVersion = + context.services.agentContext?.config.getModel() || 'Unknown'; const cliVersion = await getVersion(); const selectedAuthType = context.services.settings.merged.security.auth.selectedType || ''; @@ -44,7 +49,7 @@ export const aboutCommand: SlashCommand = { }); const userEmail = cachedAccount ?? undefined; - const tier = context.services.config?.getUserTierName(); + const tier = context.services.agentContext?.config.getUserTierName(); const aboutItem: Omit = { type: MessageType.ABOUT, @@ -64,7 +69,7 @@ export const aboutCommand: SlashCommand = { }; async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { + if (!context.services.agentContext?.config.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index 6b0a40ed5c..1a5de99122 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -26,6 +26,7 @@ describe('agentsCommand', () => { let mockContext: ReturnType; let mockConfig: { getAgentRegistry: ReturnType; + config: Config; }; beforeEach(() => { @@ -37,11 +38,14 @@ describe('agentsCommand', () => { getAllAgentNames: vi.fn().mockReturnValue([]), reload: vi.fn(), }), + get config() { + return this as unknown as Config; + }, }; mockContext = createMockCommandContext({ services: { - config: mockConfig as unknown as Config, + agentContext: mockConfig as unknown as Config, settings: { workspace: { path: '/mock/path' }, merged: { agents: { overrides: {} } }, @@ -53,7 +57,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -105,34 +109,40 @@ describe('agentsCommand', () => { ); }); - it('should reload the agent registry when refresh subcommand is called', async () => { + it('should reload the agent registry when reload subcommand is called', async () => { const reloadSpy = vi.fn().mockResolvedValue(undefined); mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ reload: reloadSpy, }); - const refreshCommand = agentsCommand.subCommands?.find( - (cmd) => cmd.name === 'refresh', + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', ); - expect(refreshCommand).toBeDefined(); + expect(reloadCommand).toBeDefined(); - const result = await refreshCommand!.action!(mockContext, ''); + const result = await reloadCommand!.action!(mockContext, ''); expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Reloading agent registry...', + }), + ); expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'Agents refreshed successfully.', + content: 'Agents reloaded successfully', }); }); - it('should show an error if agent registry is not available during refresh', async () => { + it('should show an error if agent registry is not available during reload', async () => { mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined); - const refreshCommand = agentsCommand.subCommands?.find( - (cmd) => cmd.name === 'refresh', + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', ); - const result = await refreshCommand!.action!(mockContext, ''); + const result = await reloadCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'message', @@ -220,7 +230,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available for enable', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const enableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'enable', @@ -326,7 +336,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available for disable', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', @@ -427,7 +437,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index a7161dfb77..d1b582d673 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -21,7 +21,7 @@ const agentsListCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) { return { type: 'message', @@ -61,7 +61,8 @@ async function enableAction( context: CommandContext, args: string, ): Promise { - const { config, settings } = context.services; + const config = context.services.agentContext?.config; + const { settings } = context.services; if (!config) { return { type: 'message', @@ -137,7 +138,8 @@ async function disableAction( context: CommandContext, args: string, ): Promise { - const { config, settings } = context.services; + const config = context.services.agentContext?.config; + const { settings } = context.services; if (!config) { return { type: 'message', @@ -216,7 +218,7 @@ async function configAction( context: CommandContext, args: string, ): Promise { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) { return { type: 'message', @@ -266,7 +268,8 @@ async function configAction( } function completeAgentsToEnable(context: CommandContext, partialArg: string) { - const { config, settings } = context.services; + const config = context.services.agentContext?.config; + const { settings } = context.services; if (!config) return []; const overrides = settings.merged.agents.overrides; @@ -278,7 +281,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) { } function completeAgentsToDisable(context: CommandContext, partialArg: string) { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) return []; const agentRegistry = config.getAgentRegistry(); @@ -287,7 +290,7 @@ function completeAgentsToDisable(context: CommandContext, partialArg: string) { } function completeAllAgents(context: CommandContext, partialArg: string) { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) return []; const agentRegistry = config.getAgentRegistry(); @@ -322,13 +325,13 @@ const configCommand: SlashCommand = { completion: completeAllAgents, }; -const agentsRefreshCommand: SlashCommand = { - name: 'refresh', - altNames: ['reload'], +const agentsReloadCommand: SlashCommand = { + name: 'reload', + altNames: ['refresh'], description: 'Reload the agent registry', kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { - const { config } = context.services; + const config = context.services.agentContext?.config; const agentRegistry = config?.getAgentRegistry(); if (!agentRegistry) { return { @@ -340,7 +343,7 @@ const agentsRefreshCommand: SlashCommand = { context.ui.addItem({ type: MessageType.INFO, - text: 'Refreshing agent registry...', + text: 'Reloading agent registry...', }); await agentRegistry.reload(); @@ -348,7 +351,7 @@ const agentsRefreshCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: 'Agents refreshed successfully.', + content: 'Agents reloaded successfully', }; }, }; @@ -359,7 +362,7 @@ export const agentsCommand: SlashCommand = { kind: CommandKind.BUILT_IN, subCommands: [ agentsListCommand, - agentsRefreshCommand, + agentsReloadCommand, enableCommand, disableCommand, configCommand, diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts index ba1e369b14..ff4f2ba614 100644 --- a/packages/cli/src/ui/commands/authCommand.test.ts +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -9,6 +9,7 @@ import { authCommand } from './authCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { SettingScope } from '../../config/settings.js'; +import type { GeminiClient } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); @@ -24,8 +25,10 @@ describe('authCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: vi.fn(), + agentContext: { + geminiClient: { + stripThoughtsFromHistory: vi.fn(), + }, }, }, }); @@ -34,11 +37,13 @@ describe('authCommand', () => { vi.clearAllMocks(); }); - it('should have subcommands: login and logout', () => { + it('should have subcommands: signin and signout', () => { expect(authCommand.subCommands).toBeDefined(); expect(authCommand.subCommands).toHaveLength(2); - expect(authCommand.subCommands?.[0]?.name).toBe('login'); - expect(authCommand.subCommands?.[1]?.name).toBe('logout'); + expect(authCommand.subCommands?.[0]?.name).toBe('signin'); + expect(authCommand.subCommands?.[0]?.altNames).toContain('login'); + expect(authCommand.subCommands?.[1]?.name).toBe('signout'); + expect(authCommand.subCommands?.[1]?.altNames).toContain('logout'); }); it('should return a dialog action to open the auth dialog when called with no args', () => { @@ -59,19 +64,19 @@ describe('authCommand', () => { expect(authCommand.description).toBe('Manage authentication'); }); - describe('auth login subcommand', () => { + describe('auth signin subcommand', () => { it('should return auth dialog action', () => { const loginCommand = authCommand.subCommands?.[0]; - expect(loginCommand?.name).toBe('login'); + expect(loginCommand?.name).toBe('signin'); const result = loginCommand!.action!(mockContext, ''); expect(result).toEqual({ type: 'dialog', dialog: 'auth' }); }); }); - describe('auth logout subcommand', () => { + describe('auth signout subcommand', () => { it('should clear cached credentials', async () => { const logoutCommand = authCommand.subCommands?.[1]; - expect(logoutCommand?.name).toBe('logout'); + expect(logoutCommand?.name).toBe('signout'); const { clearCachedCredentialFile } = await import( '@google/gemini-cli-core' @@ -99,17 +104,19 @@ describe('authCommand', () => { const mockStripThoughts = vi.fn(); const mockClient = { stripThoughtsFromHistory: mockStripThoughts, - } as unknown as ReturnType< - NonNullable['getGeminiClient'] - >; - - if (mockContext.services.config) { - mockContext.services.config.getGeminiClient = vi.fn(() => mockClient); + } as unknown as GeminiClient; + if (mockContext.services.agentContext?.config) { + mockContext.services.agentContext.config.getGeminiClient = vi.fn( + () => mockClient, + ); } await logoutCommand!.action!(mockContext, ''); - expect(mockStripThoughts).toHaveBeenCalled(); + expect( + mockContext.services.agentContext?.geminiClient + .stripThoughtsFromHistory, + ).toHaveBeenCalled(); }); it('should return logout action to signal explicit state change', async () => { @@ -121,7 +128,7 @@ describe('authCommand', () => { it('should handle missing config gracefully', async () => { const logoutCommand = authCommand.subCommands?.[1]; - mockContext.services.config = null; + mockContext.services.agentContext = null; const result = await logoutCommand!.action!(mockContext, ''); diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 0314555baf..084763058c 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -14,8 +14,9 @@ import { clearCachedCredentialFile } from '@google/gemini-cli-core'; import { SettingScope } from '../../config/settings.js'; const authLoginCommand: SlashCommand = { - name: 'login', - description: 'Login or change the auth method', + name: 'signin', + altNames: ['login'], + description: 'Sign in or change the authentication method', kind: CommandKind.BUILT_IN, autoExecute: true, action: (_context, _args): OpenDialogActionReturn => ({ @@ -25,8 +26,9 @@ const authLoginCommand: SlashCommand = { }; const authLogoutCommand: SlashCommand = { - name: 'logout', - description: 'Log out and clear all cached credentials', + name: 'signout', + altNames: ['logout'], + description: 'Sign out and clear all cached credentials', kind: CommandKind.BUILT_IN, action: async (context, _args): Promise => { await clearCachedCredentialFile(); @@ -37,7 +39,7 @@ const authLogoutCommand: SlashCommand = { undefined, ); // Strip thoughts from history instead of clearing completely - context.services.config?.getGeminiClient()?.stripThoughtsFromHistory(); + context.services.agentContext?.geminiClient.stripThoughtsFromHistory(); // Return logout action to signal explicit state change return { type: 'logout', diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index 88db905e77..c2c1a9a1d6 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -83,16 +83,18 @@ describe('bugCommand', () => { it('should generate the default GitHub issue URL', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => undefined, - getIdeMode: () => true, - getGeminiClient: () => ({ + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), + }, + geminiClient: { getChat: () => ({ getHistory: () => [], }), - }), - getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), + }, }, }, }); @@ -126,18 +128,20 @@ describe('bugCommand', () => { ]; const mockContext = createMockCommandContext({ services: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => undefined, - getIdeMode: () => true, - getGeminiClient: () => ({ + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + storage: { + getProjectTempDir: () => '/tmp/gemini', + }, + }, + geminiClient: { getChat: () => ({ getHistory: () => history, }), - }), - getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), - storage: { - getProjectTempDir: () => '/tmp/gemini', }, }, }, @@ -172,16 +176,18 @@ describe('bugCommand', () => { 'https://internal.bug-tracker.com/new?desc={title}&details={info}'; const mockContext = createMockCommandContext({ services: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => ({ urlTemplate: customTemplate }), - getIdeMode: () => true, - getGeminiClient: () => ({ + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => ({ urlTemplate: customTemplate }), + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + }, + geminiClient: { getChat: () => ({ getHistory: () => [], }), - }), - getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + }, }, }, }); diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 26ddb7e850..134bccc9f0 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -32,8 +32,8 @@ export const bugCommand: SlashCommand = { autoExecute: false, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); - const { config } = context.services; - + const agentContext = context.services.agentContext; + const config = agentContext?.config; const osVersion = `${process.platform} ${process.version}`; let sandboxEnv = 'no sandbox'; if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { @@ -73,7 +73,7 @@ export const bugCommand: SlashCommand = { info += `* **IDE Client:** ${ideClient}\n`; } - const chat = config?.getGeminiClient()?.getChat(); + const chat = agentContext?.geminiClient?.getChat(); const history = chat?.getHistory() || []; let historyFileMessage = ''; let problemValue = bugDescription; @@ -134,7 +134,7 @@ export const bugCommand: SlashCommand = { }; async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { + if (!context.services.agentContext?.config.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index 6ff8d8a52e..04d0753ee8 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -70,18 +70,19 @@ describe('chatCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getProjectRoot: () => '/project/root', - getGeminiClient: () => - ({ - getChat: mockGetChat, - }) as unknown as GeminiClient, - storage: { - getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', + agentContext: { + config: { + getProjectRoot: () => '/project/root', + getContentGeneratorConfig: () => ({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + storage: { + getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', + }, }, - getContentGeneratorConfig: () => ({ - authType: AuthType.LOGIN_WITH_GOOGLE, - }), + geminiClient: { + getChat: mockGetChat, + } as unknown as GeminiClient, }, logger: { saveCheckpoint: mockSaveCheckpoint, @@ -99,8 +100,11 @@ describe('chatCommand', () => { it('should have the correct main command definition', () => { expect(chatCommand.name).toBe('chat'); - expect(chatCommand.description).toBe('Manage conversation history'); - expect(chatCommand.subCommands).toHaveLength(5); + expect(chatCommand.description).toBe( + 'Browse auto-saved conversations and manage chat checkpoints', + ); + expect(chatCommand.autoExecute).toBe(true); + expect(chatCommand.subCommands).toHaveLength(6); }); describe('list subcommand', () => { @@ -158,7 +162,7 @@ describe('chatCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat save ', + content: 'Missing tag. Usage: /resume save ', }); }); @@ -252,7 +256,7 @@ describe('chatCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat resume ', + content: 'Missing tag. Usage: /resume resume ', }); }); @@ -386,7 +390,7 @@ describe('chatCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat delete ', + content: 'Missing tag. Usage: /resume delete ', }); }); @@ -695,7 +699,11 @@ Hi there!`; beforeEach(() => { mockGetLatestApiRequest = vi.fn(); - mockContext.services.config!.getLatestApiRequest = + if (!mockContext.services.agentContext!.config) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext!.config as any) = {}; + } + mockContext.services.agentContext!.config.getLatestApiRequest = mockGetLatestApiRequest; vi.spyOn(process, 'cwd').mockReturnValue('/project/root'); vi.spyOn(Date, 'now').mockReturnValue(1234567890); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index e1969fff67..87aacb056b 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -29,11 +29,13 @@ import { MessageType } from '../types.js'; import { exportHistoryToFile } from '../utils/historyExportUtils.js'; import { convertToRestPayload } from '@google/gemini-cli-core'; +const CHECKPOINT_MENU_GROUP = 'checkpoints'; + const getSavedChatTags = async ( context: CommandContext, mtSortDesc: boolean, ): Promise => { - const cfg = context.services.config; + const cfg = context.services.agentContext?.config; const geminiDir = cfg?.storage?.getProjectTempDir(); if (!geminiDir) { return []; @@ -70,7 +72,7 @@ const getSavedChatTags = async ( const listCommand: SlashCommand = { name: 'list', - description: 'List saved conversation checkpoints', + description: 'List saved manual conversation checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { @@ -88,7 +90,7 @@ const listCommand: SlashCommand = { const saveCommand: SlashCommand = { name: 'save', description: - 'Save the current conversation as a checkpoint. Usage: /chat save ', + 'Save the current conversation as a checkpoint. Usage: /resume save ', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context, args): Promise => { @@ -97,11 +99,12 @@ const saveCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat save ', + content: 'Missing tag. Usage: /resume save ', }; } - const { logger, config } = context.services; + const { logger } = context.services; + const config = context.services.agentContext?.config; await logger.initialize(); if (!context.overwriteConfirmed) { @@ -117,13 +120,13 @@ const saveCommand: SlashCommand = { ' already exists. Do you want to overwrite it?', ), originalInvocation: { - raw: context.invocation?.raw || `/chat save ${tag}`, + raw: context.invocation?.raw || `/resume save ${tag}`, }, }; } } - const chat = config?.getGeminiClient()?.getChat(); + const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', @@ -153,11 +156,11 @@ const saveCommand: SlashCommand = { }, }; -const resumeCommand: SlashCommand = { +const resumeCheckpointCommand: SlashCommand = { name: 'resume', altNames: ['load'], description: - 'Resume a conversation from a checkpoint. Usage: /chat resume ', + 'Resume a conversation from a checkpoint. Usage: /resume resume ', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args) => { @@ -166,11 +169,12 @@ const resumeCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat resume ', + content: 'Missing tag. Usage: /resume resume ', }; } - const { logger, config } = context.services; + const { logger } = context.services; + const config = context.services.agentContext?.config; await logger.initialize(); const checkpoint = await logger.loadCheckpoint(tag); const conversation = checkpoint.history; @@ -235,7 +239,7 @@ const resumeCommand: SlashCommand = { const deleteCommand: SlashCommand = { name: 'delete', - description: 'Delete a conversation checkpoint. Usage: /chat delete ', + description: 'Delete a conversation checkpoint. Usage: /resume delete ', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { @@ -244,7 +248,7 @@ const deleteCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat delete ', + content: 'Missing tag. Usage: /resume delete ', }; } @@ -277,7 +281,7 @@ const deleteCommand: SlashCommand = { const shareCommand: SlashCommand = { name: 'share', description: - 'Share the current conversation to a markdown or json file. Usage: /chat share ', + 'Share the current conversation to a markdown or json file. Usage: /resume share ', kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context, args): Promise => { @@ -296,7 +300,7 @@ const shareCommand: SlashCommand = { }; } - const chat = context.services.config?.getGeminiClient()?.getChat(); + const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', @@ -342,7 +346,7 @@ export const debugCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { - const req = context.services.config?.getLatestApiRequest(); + const req = context.services.agentContext?.config.getLatestApiRequest(); if (!req) { return { type: 'message', @@ -376,16 +380,40 @@ export const debugCommand: SlashCommand = { }, }; +export const checkpointSubCommands: SlashCommand[] = [ + listCommand, + saveCommand, + resumeCheckpointCommand, + deleteCommand, + shareCommand, +]; + +const checkpointCompatibilityCommand: SlashCommand = { + name: 'checkpoints', + altNames: ['checkpoint'], + description: 'Compatibility command for nested checkpoint operations', + kind: CommandKind.BUILT_IN, + hidden: true, + autoExecute: false, + subCommands: checkpointSubCommands, +}; + +export const chatResumeSubCommands: SlashCommand[] = [ + ...checkpointSubCommands.map((subCommand) => ({ + ...subCommand, + suggestionGroup: CHECKPOINT_MENU_GROUP, + })), + checkpointCompatibilityCommand, +]; + export const chatCommand: SlashCommand = { name: 'chat', - description: 'Manage conversation history', + description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, - autoExecute: false, - subCommands: [ - listCommand, - saveCommand, - resumeCommand, - deleteCommand, - shareCommand, - ], + autoExecute: true, + action: async () => ({ + type: 'dialog', + dialog: 'sessionBrowser', + }), + subCommands: chatResumeSubCommands, }; diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index d33dc5884d..77f6e4854d 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Mock } from 'vitest'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { clearCommand } from './clearCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; @@ -17,12 +16,12 @@ vi.mock('@google/gemini-cli-core', async () => { ...actual, uiTelemetryService: { setLastPromptTokenCount: vi.fn(), + clear: vi.fn(), }, }; }); -import type { GeminiClient } from '@google/gemini-cli-core'; -import { uiTelemetryService } from '@google/gemini-cli-core'; +import { uiTelemetryService, type GeminiClient } from '@google/gemini-cli-core'; describe('clearCommand', () => { let mockContext: CommandContext; @@ -37,24 +36,25 @@ describe('clearCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => - ({ - resetChat: mockResetChat, - getChat: () => ({ - getChatRecordingService: mockGetChatRecordingService, - }), - }) as unknown as GeminiClient, - setSessionId: vi.fn(), - getEnableHooks: vi.fn().mockReturnValue(false), - getMessageBus: vi.fn().mockReturnValue(undefined), - getHookSystem: vi.fn().mockReturnValue({ - fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), - fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), - }), - userHintService: { - clear: mockHintClear, + agentContext: { + config: { + getEnableHooks: vi.fn().mockReturnValue(false), + setSessionId: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), + getHookSystem: vi.fn().mockReturnValue({ + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), + }), + injectionService: { + clear: mockHintClear, + }, }, + geminiClient: { + resetChat: mockResetChat, + getChat: () => ({ + getChatRecordingService: mockGetChatRecordingService, + }), + } as unknown as GeminiClient, }, }, }); @@ -74,17 +74,16 @@ describe('clearCommand', () => { expect(mockResetChat).toHaveBeenCalledTimes(1); expect(mockHintClear).toHaveBeenCalledTimes(1); - expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0); - expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1); + expect(uiTelemetryService.clear).toHaveBeenCalled(); + expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); expect(mockContext.ui.clear).toHaveBeenCalledTimes(1); // Check the order of operations. const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock .invocationCallOrder[0]; const resetChatOrder = mockResetChat.mock.invocationCallOrder[0]; - const resetTelemetryOrder = ( - uiTelemetryService.setLastPromptTokenCount as Mock - ).mock.invocationCallOrder[0]; + const resetTelemetryOrder = (uiTelemetryService.clear as Mock).mock + .invocationCallOrder[0]; const clearOrder = (mockContext.ui.clear as Mock).mock .invocationCallOrder[0]; @@ -100,7 +99,7 @@ describe('clearCommand', () => { const nullConfigContext = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -110,8 +109,8 @@ describe('clearCommand', () => { 'Clearing terminal.', ); expect(mockResetChat).not.toHaveBeenCalled(); - expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0); - expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1); + expect(uiTelemetryService.clear).toHaveBeenCalled(); + expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 385d3f9540..061c4f9085 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -10,8 +10,7 @@ import { SessionStartSource, flushTelemetry, } from '@google/gemini-cli-core'; -import type { SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { CommandKind, type SlashCommand } from './types.js'; import { MessageType } from '../types.js'; import { randomUUID } from 'node:crypto'; @@ -21,12 +20,8 @@ export const clearCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args) => { - const geminiClient = context.services.config?.getGeminiClient(); - const config = context.services.config; - const chatRecordingService = context.services.config - ?.getGeminiClient() - ?.getChat() - .getChatRecordingService(); + const geminiClient = context.services.agentContext?.geminiClient; + const config = context.services.agentContext?.config; // Fire SessionEnd hook before clearing const hookSystem = config?.getHookSystem(); @@ -34,6 +29,18 @@ export const clearCommand: SlashCommand = { await hookSystem.fireSessionEndEvent(SessionEndReason.Clear); } + // Reset user steering hints + config?.injectionService.clear(); + + // Start a new conversation recording with a new session ID + // We MUST do this before calling resetChat() so the new ChatRecordingService + // initialized by GeminiChat picks up the new session ID. + let newSessionId: string | undefined; + if (config) { + newSessionId = randomUUID(); + config.setSessionId(newSessionId); + } + if (geminiClient) { context.ui.setDebugMessage('Clearing terminal and resetting chat.'); // If resetChat fails, the exception will propagate and halt the command, @@ -43,16 +50,6 @@ export const clearCommand: SlashCommand = { context.ui.setDebugMessage('Clearing terminal.'); } - // Reset user steering hints - config?.userHintService.clear(); - - // Start a new conversation recording with a new session ID - if (config && chatRecordingService) { - const newSessionId = randomUUID(); - config.setSessionId(newSessionId); - chatRecordingService.initialize(); - } - // Fire SessionStart hook after clearing let result; if (hookSystem) { @@ -69,7 +66,7 @@ export const clearCommand: SlashCommand = { await flushTelemetry(config); } - uiTelemetryService.setLastPromptTokenCount(0); + uiTelemetryService.clear(newSessionId); context.ui.clear(); if (result?.systemMessage) { diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index ed1e134560..fd60b54354 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -22,11 +22,10 @@ describe('compressCommand', () => { mockTryCompressChat = vi.fn(); context = createMockCommandContext({ services: { - config: { - getGeminiClient: () => - ({ - tryCompressChat: mockTryCompressChat, - }) as unknown as GeminiClient, + agentContext: { + geminiClient: { + tryCompressChat: mockTryCompressChat, + } as unknown as GeminiClient, }, }, }); @@ -131,4 +130,12 @@ describe('compressCommand', () => { await compressCommand.action!(context, ''); expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); }); + + describe('metadata', () => { + it('should have the correct name and aliases', () => { + expect(compressCommand.name).toBe('compress'); + expect(compressCommand.altNames).toContain('summarize'); + expect(compressCommand.altNames).toContain('compact'); + }); + }); }); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 3bb5b34383..6d53667010 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -4,14 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { HistoryItemCompression } from '../types.js'; -import { MessageType } from '../types.js'; -import type { SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { MessageType, type HistoryItemCompression } from '../types.js'; +import { CommandKind, type SlashCommand } from './types.js'; export const compressCommand: SlashCommand = { name: 'compress', - altNames: ['summarize'], + altNames: ['summarize', 'compact'], description: 'Compresses the context by replacing it with a summary', kind: CommandKind.BUILT_IN, autoExecute: true, @@ -41,9 +39,11 @@ export const compressCommand: SlashCommand = { try { ui.setPendingItem(pendingMessage); const promptId = `compress-${Date.now()}`; - const compressed = await context.services.config - ?.getGeminiClient() - ?.tryCompressChat(promptId, true); + const compressed = + await context.services.agentContext?.geminiClient?.tryCompressChat( + promptId, + true, + ); if (compressed) { ui.addItem( { diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index e8aace1bcc..6a1d36ca21 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Mock } from 'vitest'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { copyCommand } from './copyCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; @@ -30,10 +29,10 @@ describe('copyCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ + agentContext: { + geminiClient: { getChat: mockGetChat, - }), + }, }, }, }); @@ -302,7 +301,7 @@ describe('copyCommand', () => { if (!copyCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const result = await copyCommand.action(nullConfigContext, ''); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index c2c6ab13d1..746d6899a6 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -6,8 +6,11 @@ import { debugLogger } from '@google/gemini-cli-core'; import { copyToClipboard } from '../utils/commandUtils.js'; -import type { SlashCommand, SlashCommandActionReturn } from './types.js'; -import { CommandKind } from './types.js'; +import { + CommandKind, + type SlashCommand, + type SlashCommandActionReturn, +} from './types.js'; export const copyCommand: SlashCommand = { name: 'copy', @@ -15,7 +18,7 @@ export const copyCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args): Promise => { - const chat = context.services.config?.getGeminiClient()?.getChat(); + const chat = context.services.agentContext?.geminiClient?.getChat(); const history = chat?.getHistory(); // Get the last message from the AI (model role) diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index d9c534a89e..837bc696b7 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -4,8 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import type { Mock } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import { directoryCommand } from './directoryCommand.js'; import { expandHomeDir, @@ -78,11 +85,14 @@ describe('directoryCommand', () => { getFileFilteringOptions: () => ({ ignore: [], include: [] }), setUserMemory: vi.fn(), setGeminiMdFileCount: vi.fn(), + get config() { + return this; + }, } as unknown as Config; mockContext = { services: { - config: mockConfig, + agentContext: mockConfig, settings: { merged: { memoryDiscoveryMaxDirs: 1000, diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 08a65ca78a..4106efa97b 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -9,16 +9,21 @@ import { loadTrustedFolders, } from '../../config/trustedFolders.js'; import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js'; -import type { SlashCommand, CommandContext } from './types.js'; -import { CommandKind } from './types.js'; +import { + CommandKind, + type SlashCommand, + type CommandContext, +} from './types.js'; import { MessageType, type HistoryItem } from '../types.js'; -import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core'; +import { + refreshServerHierarchicalMemory, + type Config, +} from '@google/gemini-cli-core'; import { expandHomeDir, getDirectorySuggestions, batchAddDirectories, } from '../utils/directoryUtils.js'; -import type { Config } from '@google/gemini-cli-core'; import * as path from 'node:path'; import * as fs from 'node:fs'; @@ -55,7 +60,7 @@ async function finishAddingDirectories( } if (added.length > 0) { - const gemini = config.getGeminiClient(); + const gemini = config.geminiClient; if (gemini) { await gemini.addDirectoryContext(); @@ -105,9 +110,9 @@ export const directoryCommand: SlashCommand = { // Filter out existing directories let filteredSuggestions = suggestions; - if (context.services.config) { + if (context.services.agentContext?.config) { const workspaceContext = - context.services.config.getWorkspaceContext(); + context.services.agentContext.config.getWorkspaceContext(); const existingDirs = new Set( workspaceContext.getDirectories().map((dir) => path.resolve(dir)), ); @@ -139,11 +144,11 @@ export const directoryCommand: SlashCommand = { action: async (context: CommandContext, args: string) => { const { ui: { addItem }, - services: { config, settings }, + services: { agentContext, settings }, } = context; const [...rest] = args.split(' '); - if (!config) { + if (!agentContext) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', @@ -151,7 +156,7 @@ export const directoryCommand: SlashCommand = { return; } - if (config.isRestrictiveSandbox()) { + if (agentContext.config.isRestrictiveSandbox()) { return { type: 'message' as const, messageType: 'error' as const, @@ -176,7 +181,7 @@ export const directoryCommand: SlashCommand = { const errors: string[] = []; const alreadyAdded: string[] = []; - const workspaceContext = config.getWorkspaceContext(); + const workspaceContext = agentContext.config.getWorkspaceContext(); const currentWorkspaceDirs = workspaceContext.getDirectories(); const pathsToProcess: string[] = []; @@ -247,7 +252,7 @@ export const directoryCommand: SlashCommand = { trustedDirs={added} errors={errors} finishAddingDirectories={finishAddingDirectories} - config={config} + config={agentContext.config} addItem={addItem} /> ), @@ -259,7 +264,12 @@ export const directoryCommand: SlashCommand = { errors.push(...result.errors); } - await finishAddingDirectories(config, addItem, added, errors); + await finishAddingDirectories( + agentContext.config, + addItem, + added, + errors, + ); return; }, }, @@ -270,16 +280,16 @@ export const directoryCommand: SlashCommand = { action: async (context: CommandContext) => { const { ui: { addItem }, - services: { config }, + services: { agentContext }, } = context; - if (!config) { + if (!agentContext) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', }); return; } - const workspaceContext = config.getWorkspaceContext(); + const workspaceContext = agentContext.config.getWorkspaceContext(); const directories = workspaceContext.getDirectories(); const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); addItem({ diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index cc862b6c42..8f065438e2 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -21,6 +21,10 @@ import { ConfigExtensionDialog, type ConfigExtensionDialogProps, } from '../components/ConfigExtensionDialog.js'; +import { + ExtensionRegistryView, + type ExtensionRegistryViewProps, +} from '../components/views/ExtensionRegistryView.js'; import { type CommandContext, type SlashCommand } from './types.js'; import { @@ -39,6 +43,8 @@ import { } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; import { stat } from 'node:fs/promises'; +import { type RegistryExtension } from '../../config/extensionRegistryClient.js'; +import { waitFor } from '../../test-utils/async.js'; vi.mock('../../config/extension-manager.js', async (importOriginal) => { const actual = @@ -155,18 +161,21 @@ describe('extensionsCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getExtensions: mockGetExtensions, - getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), - getWorkingDir: () => '/test/dir', - reloadSkills: mockReloadSkills, - getAgentRegistry: vi.fn().mockReturnValue({ - reload: mockReloadAgents, - }), + agentContext: { + config: { + getExtensions: mockGetExtensions, + getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), + getWorkingDir: () => '/test/dir', + reloadSkills: mockReloadSkills, + getAgentRegistry: vi.fn().mockReturnValue({ + reload: mockReloadAgents, + }), + }, }, }, ui: { dispatchExtensionStateUpdate: mockDispatchExtensionState, + removeComponent: vi.fn(), }, }); }); @@ -429,6 +438,65 @@ describe('extensionsCommand', () => { throw new Error('Explore action not found'); } + it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = true; + + const result = await exploreAction(mockContext, ''); + + expect(result).toBeDefined(); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + expect(component.type).toBe(ExtensionRegistryView); + expect(component.props.extensionManager).toBe(mockExtensionLoader); + }); + + it('should handle onSelect and onClose in ExtensionRegistryView', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = true; + + const result = await exploreAction(mockContext, ''); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + + const extension = { + extensionName: 'test-ext', + url: 'https://github.com/test/ext.git', + } as RegistryExtension; + + vi.mocked(inferInstallMetadata).mockResolvedValue({ + source: extension.url, + type: 'git', + }); + mockInstallExtension.mockResolvedValue({ name: extension.url }); + + // Call onSelect + await component.props.onSelect?.(extension); + + await waitFor(() => { + expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: extension.url, + type: 'git', + }, + undefined, + undefined, + ); + }); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); + + // Call onClose + component.props.onClose?.(); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2); + }); + it("should add an info message and call 'open' in a non-sandbox environment", async () => { // Ensure no special environment variables that would affect behavior vi.stubEnv('NODE_ENV', ''); @@ -560,10 +628,14 @@ describe('extensionsCommand', () => { mockInstallExtension.mockResolvedValue({ name: packageName }); await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Installing extension from "${packageName}"...`, @@ -585,10 +657,14 @@ describe('extensionsCommand', () => { await installAction!(mockContext, packageName); expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'git', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to install extension from "${packageName}": ${errorMessage}`, @@ -634,10 +710,14 @@ describe('extensionsCommand', () => { size: 100, } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Linking extension from "${packageName}"...`, @@ -657,10 +737,14 @@ describe('extensionsCommand', () => { } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to link extension from "${packageName}": ${errorMessage}`, @@ -693,7 +777,7 @@ describe('extensionsCommand', () => { await uninstallAction!(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, - text: 'Usage: /extensions uninstall ', + text: 'Usage: /extensions uninstall |--all', }); expect(mockUninstallExtension).not.toHaveBeenCalled(); }); @@ -830,7 +914,7 @@ describe('extensionsCommand', () => { }); }); - describe('restart', () => { + describe('reload', () => { let restartAction: SlashCommand['action']; let mockRestartExtension: MockedFunction< typeof ExtensionLoader.prototype.restartExtension @@ -838,22 +922,22 @@ describe('extensionsCommand', () => { beforeEach(() => { restartAction = extensionsCommand().subCommands?.find( - (c) => c.name === 'restart', + (c) => c.name === 'reload', )?.action; expect(restartAction).not.toBeNull(); mockRestartExtension = vi.fn(); - mockContext.services.config!.getExtensionLoader = vi + mockContext.services.agentContext!.config.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: mockGetExtensions, restartExtension: mockRestartExtension, })); - mockContext.invocation!.name = 'restart'; + mockContext.invocation!.name = 'reload'; }); it('should show a message if no extensions are installed', async () => { - mockContext.services.config!.getExtensionLoader = vi + mockContext.services.agentContext!.config.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: () => [], @@ -868,7 +952,7 @@ describe('extensionsCommand', () => { }); }); - it('restarts all active extensions when --all is provided', async () => { + it('reloads all active extensions when --all is provided', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, { name: 'ext2', isActive: true }, @@ -884,13 +968,13 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: 'Restarting 2 extensions...', + text: 'Reloading 2 extensions...', }), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: '2 extensions restarted successfully.', + text: '2 extensions reloaded successfully', }), ); expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ @@ -924,7 +1008,7 @@ describe('extensionsCommand', () => { ); }); - it('restarts only specified active extensions', async () => { + it('reloads only specified active extensions', async () => { const mockExtensions = [ { name: 'ext1', isActive: false }, { name: 'ext2', isActive: true }, @@ -943,7 +1027,7 @@ describe('extensionsCommand', () => { }); it('shows an error if no extension loader is available', async () => { - mockContext.services.config!.getExtensionLoader = vi.fn(); + mockContext.services.agentContext!.config.getExtensionLoader = vi.fn(); await restartAction!(mockContext, '--all'); @@ -962,13 +1046,13 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Usage: /extensions restart |--all', + text: 'Usage: /extensions reload |--all', }), ); expect(mockRestartExtension).not.toHaveBeenCalled(); }); - it('handles errors during extension restart', async () => { + it('handles errors during extension reload', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; @@ -981,7 +1065,7 @@ describe('extensionsCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Failed to restart some extensions:\n ext1: Failed to restart', + text: 'Failed to reload some extensions:\n ext1: Failed to restart', }), ); }); @@ -1004,7 +1088,7 @@ describe('extensionsCommand', () => { ); }); - it('does not restart any extensions if none are found', async () => { + it('does not reload any extensions if none are found', async () => { const mockExtensions = [ { name: 'ext1', isActive: true }, ] as GeminiCLIExtension[]; @@ -1021,8 +1105,8 @@ describe('extensionsCommand', () => { ); }); - it('should suggest only enabled extension names for the restart command', async () => { - mockContext.invocation!.name = 'restart'; + it('should suggest only enabled extension names for the reload command', async () => { + mockContext.invocation!.name = 'reload'; const mockExtensions = [ { name: 'ext1', isActive: true }, { name: 'ext2', isActive: false }, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 0a8a8d74e3..aed7595389 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -7,10 +7,10 @@ import { debugLogger, listExtensions, + getErrorMessage, type ExtensionInstallMetadata, } from '@google/gemini-cli-core'; import type { ExtensionUpdateInfo } from '../../config/extension.js'; -import { getErrorMessage } from '../../utils/errors.js'; import { emptyIcon, MessageType, @@ -54,8 +54,8 @@ function showMessageIfNoExtensions( } async function listAction(context: CommandContext) { - const extensions = context.services.config - ? listExtensions(context.services.config) + const extensions = context.services.agentContext?.config + ? listExtensions(context.services.agentContext.config) : []; if (showMessageIfNoExtensions(context, extensions)) { @@ -88,8 +88,8 @@ function updateAction(context: CommandContext, args: string): Promise { (resolve) => (resolveUpdateComplete = resolve), ); - const extensions = context.services.config - ? listExtensions(context.services.config) + const extensions = context.services.agentContext?.config + ? listExtensions(context.services.agentContext.config) : []; if (showMessageIfNoExtensions(context, extensions)) { @@ -128,7 +128,7 @@ function updateAction(context: CommandContext, args: string): Promise { }, }); if (names?.length) { - const extensions = listExtensions(context.services.config!); + const extensions = listExtensions(context.services.agentContext!.config); for (const name of names) { const extension = extensions.find( (extension) => extension.name === name, @@ -156,7 +156,8 @@ async function restartAction( context: CommandContext, args: string, ): Promise { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!extensionLoader) { context.ui.addItem({ type: MessageType.ERROR, @@ -176,7 +177,7 @@ async function restartAction( if (!all && names?.length === 0) { context.ui.addItem({ type: MessageType.ERROR, - text: 'Usage: /extensions restart |--all', + text: 'Usage: /extensions reload |--all', }); return Promise.resolve(); } @@ -208,12 +209,12 @@ async function restartAction( const s = extensionsToRestart.length > 1 ? 's' : ''; - const restartingMessage = { + const reloadingMessage = { type: MessageType.INFO, - text: `Restarting ${extensionsToRestart.length} extension${s}...`, + text: `Reloading ${extensionsToRestart.length} extension${s}...`, color: theme.text.primary, }; - context.ui.addItem(restartingMessage); + context.ui.addItem(reloadingMessage); const results = await Promise.allSettled( extensionsToRestart.map(async (extension) => { @@ -235,8 +236,8 @@ async function restartAction( if (failures.length < extensionsToRestart.length) { try { - await context.services.config?.reloadSkills(); - await context.services.config?.getAgentRegistry()?.reload(); + await context.services.agentContext?.config.reloadSkills(); + await context.services.agentContext?.config.getAgentRegistry()?.reload(); } catch (error) { context.ui.addItem({ type: MessageType.ERROR, @@ -254,12 +255,12 @@ async function restartAction( .join('\n '); context.ui.addItem({ type: MessageType.ERROR, - text: `Failed to restart some extensions:\n ${errorMessages}`, + text: `Failed to reload some extensions:\n ${errorMessages}`, }); } else { const infoItem: HistoryItemInfo = { type: MessageType.INFO, - text: `${extensionsToRestart.length} extension${s} restarted successfully.`, + text: `${extensionsToRestart.length} extension${s} reloaded successfully`, icon: emptyIcon, color: theme.text.primary, }; @@ -274,13 +275,21 @@ async function exploreAction( const useRegistryUI = settings.experimental?.extensionRegistry; if (useRegistryUI) { - const extensionManager = context.services.config?.getExtensionLoader(); + const extensionManager = + context.services.agentContext?.config.getExtensionLoader(); if (extensionManager instanceof ExtensionManager) { return { type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { - onSelect: (extension) => { - debugLogger.debug(`Selected extension: ${extension.extensionName}`); + onSelect: async (extension, requestConsentOverride) => { + debugLogger.log(`Selected extension: ${extension.extensionName}`); + await installAction(context, extension.url, requestConsentOverride); + context.ui.removeComponent(); + }, + onLink: async (extension, requestConsentOverride) => { + debugLogger.log(`Linking extension: ${extension.extensionName}`); + await linkAction(context, extension.url, requestConsentOverride); + context.ui.removeComponent(); }, onClose: () => context.ui.removeComponent(), extensionManager, @@ -329,7 +338,8 @@ function getEnableDisableContext( names: string[]; scope: SettingScope; } | null { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -429,7 +439,8 @@ async function enableAction(context: CommandContext, args: string) { if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); - const mcpClientManager = context.services.config?.getMcpClientManager(); + const mcpClientManager = + context.services.agentContext?.config.getMcpClientManager(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers ?? {}), ); @@ -456,8 +467,13 @@ async function enableAction(context: CommandContext, args: string) { } } -async function installAction(context: CommandContext, args: string) { - const extensionLoader = context.services.config?.getExtensionLoader(); +async function installAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -503,8 +519,11 @@ async function installAction(context: CommandContext, args: string) { try { const installMetadata = await inferInstallMetadata(source); - const extension = - await extensionLoader.installOrUpdateExtension(installMetadata); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" installed successfully.`, @@ -519,8 +538,13 @@ async function installAction(context: CommandContext, args: string) { } } -async function linkAction(context: CommandContext, args: string) { - const extensionLoader = context.services.config?.getExtensionLoader(); +async function linkAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -567,8 +591,11 @@ async function linkAction(context: CommandContext, args: string) { source: sourceFilepath, type: 'link', }; - const extension = - await extensionLoader.installOrUpdateExtension(installMetadata); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" linked successfully.`, @@ -584,7 +611,8 @@ async function linkAction(context: CommandContext, args: string) { } async function uninstallAction(context: CommandContext, args: string) { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -592,33 +620,53 @@ async function uninstallAction(context: CommandContext, args: string) { return; } - const name = args.trim(); - if (!name) { + const uninstallArgs = args.split(' ').filter((value) => value.length > 0); + const all = uninstallArgs.includes('--all'); + const names = uninstallArgs.filter((a) => !a.startsWith('--')); + + if (!all && names.length === 0) { context.ui.addItem({ type: MessageType.ERROR, - text: `Usage: /extensions uninstall `, + text: `Usage: /extensions uninstall |--all`, }); return; } - context.ui.addItem({ - type: MessageType.INFO, - text: `Uninstalling extension "${name}"...`, - }); + let namesToUninstall: string[] = []; + if (all) { + namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name); + } else { + namesToUninstall = names; + } - try { - await extensionLoader.uninstallExtension(name, false); + if (namesToUninstall.length === 0) { context.ui.addItem({ type: MessageType.INFO, - text: `Extension "${name}" uninstalled successfully.`, + text: all ? 'No extensions installed.' : 'No extension name provided.', }); - } catch (error) { + return; + } + + for (const extensionName of namesToUninstall) { context.ui.addItem({ - type: MessageType.ERROR, - text: `Failed to uninstall extension "${name}": ${getErrorMessage( - error, - )}`, + type: MessageType.INFO, + text: `Uninstalling extension "${extensionName}"...`, }); + + try { + await extensionLoader.uninstallExtension(extensionName, false); + context.ui.addItem({ + type: MessageType.INFO, + text: `Extension "${extensionName}" uninstalled successfully.`, + }); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to uninstall extension "${extensionName}": ${getErrorMessage( + error, + )}`, + }); + } } } @@ -663,7 +711,8 @@ async function configAction(context: CommandContext, args: string) { } } - const extensionManager = context.services.config?.getExtensionLoader(); + const extensionManager = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionManager instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -700,14 +749,15 @@ export function completeExtensions( context: CommandContext, partialArg: string, ) { - let extensions = context.services.config?.getExtensions() ?? []; + let extensions = context.services.agentContext?.config.getExtensions() ?? []; if (context.invocation?.name === 'enable') { extensions = extensions.filter((ext) => !ext.isActive); } if ( context.invocation?.name === 'disable' || - context.invocation?.name === 'restart' + context.invocation?.name === 'restart' || + context.invocation?.name === 'reload' ) { extensions = extensions.filter((ext) => ext.isActive); } @@ -802,9 +852,10 @@ const exploreExtensionsCommand: SlashCommand = { action: exploreAction, }; -const restartCommand: SlashCommand = { - name: 'restart', - description: 'Restart all extensions', +const reloadCommand: SlashCommand = { + name: 'reload', + altNames: ['restart'], + description: 'Reload all extensions', kind: CommandKind.BUILT_IN, autoExecute: false, action: restartAction, @@ -841,7 +892,7 @@ export function extensionsCommand( listExtensionsCommand, updateExtensionsCommand, exploreExtensionsCommand, - restartCommand, + reloadCommand, ...conditionalCommands, ], action: (context, args) => diff --git a/packages/cli/src/ui/commands/footerCommand.tsx b/packages/cli/src/ui/commands/footerCommand.tsx new file mode 100644 index 0000000000..4a6760e229 --- /dev/null +++ b/packages/cli/src/ui/commands/footerCommand.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type SlashCommand, + type CommandContext, + type OpenCustomDialogActionReturn, + CommandKind, +} from './types.js'; +import { FooterConfigDialog } from '../components/FooterConfigDialog.js'; + +export const footerCommand: SlashCommand = { + name: 'footer', + altNames: ['statusline'], + description: 'Configure which items appear in the footer (statusline)', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (context: CommandContext): OpenCustomDialogActionReturn => ({ + type: 'custom_dialog', + component: , + }), +}; diff --git a/packages/cli/src/ui/commands/helpCommand.test.ts b/packages/cli/src/ui/commands/helpCommand.test.ts index 58b02251f9..a961a99b26 100644 --- a/packages/cli/src/ui/commands/helpCommand.test.ts +++ b/packages/cli/src/ui/commands/helpCommand.test.ts @@ -6,10 +6,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { helpCommand } from './helpCommand.js'; -import { type CommandContext } from './types.js'; +import { CommandKind, type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; -import { CommandKind } from './types.js'; describe('helpCommand', () => { let mockContext: CommandContext; diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index ce2ff36d9c..1f234a3bc8 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { CommandKind, type SlashCommand } from './types.js'; import { MessageType, type HistoryItemHelp } from '../types.js'; export const helpCommand: SlashCommand = { diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index ed7f7bb747..0059f86105 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -7,9 +7,12 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { hooksCommand } from './hooksCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { MessageType } from '../types.js'; -import type { HookRegistryEntry } from '@google/gemini-cli-core'; -import { HookType, HookEventName, ConfigSource } from '@google/gemini-cli-core'; +import { + HookType, + HookEventName, + ConfigSource, + type HookRegistryEntry, +} from '@google/gemini-cli-core'; import type { CommandContext } from './types.js'; import { SettingScope } from '../../config/settings.js'; @@ -90,7 +93,7 @@ describe('hooksCommand', () => { // Create mock context with config and settings mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: { config: mockConfig }, settings: mockSettings, }, }); @@ -127,13 +130,10 @@ describe('hooksCommand', () => { createMockHook('test-hook', HookEventName.BeforeTool, true), ]); - await hooksCommand.action(mockContext, ''); + const result = await hooksCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); }); @@ -141,7 +141,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -161,7 +161,7 @@ describe('hooksCommand', () => { }); }); - it('should display panel even when hook system is not enabled', async () => { + it('should return custom_dialog even when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const panelCmd = hooksCommand.subCommands!.find( @@ -171,17 +171,13 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: [], - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); - it('should display panel when no hooks are configured', async () => { + it('should return custom_dialog when no hooks are configured', async () => { mockHookSystem.getAllHooks.mockReturnValue([]); (mockContext.services.settings.merged as Record)[ 'hooksConfig' @@ -194,17 +190,13 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: [], - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); - it('should display hooks list when hooks are configured', async () => { + it('should return custom_dialog when hooks are configured', async () => { const mockHooks: HookRegistryEntry[] = [ createMockHook('echo-test', HookEventName.BeforeTool, true), createMockHook('notify', HookEventName.AfterAgent, false), @@ -222,14 +214,10 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: mockHooks, - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); }); @@ -237,7 +225,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -350,7 +338,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -482,7 +470,7 @@ describe('hooksCommand', () => { it('should return empty array when config is not available', () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -579,7 +567,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -703,7 +691,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 92fa72b235..4bdc9ead54 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -4,9 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand, CommandContext } from './types.js'; +import { createElement } from 'react'; +import type { + SlashCommand, + CommandContext, + OpenCustomDialogActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; -import { MessageType, type HistoryItemHooksList } from '../types.js'; import type { HookRegistryEntry, MessageActionReturn, @@ -15,14 +19,16 @@ import { getErrorMessage } from '@google/gemini-cli-core'; import { SettingScope, isLoadableSettingScope } from '../../config/settings.js'; import { enableHook, disableHook } from '../../utils/hookSettings.js'; import { renderHookActionFeedback } from '../../utils/hookUtils.js'; +import { HooksDialog } from '../components/HooksDialog.js'; /** - * Display a formatted list of hooks with their status + * Display a formatted list of hooks with their status in a dialog */ -async function panelAction( +function panelAction( context: CommandContext, -): Promise { - const { config } = context.services; +): MessageActionReturn | OpenCustomDialogActionReturn { + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -34,12 +40,13 @@ async function panelAction( const hookSystem = config.getHookSystem(); const allHooks = hookSystem?.getAllHooks() || []; - const hooksListItem: HistoryItemHooksList = { - type: MessageType.HOOKS_LIST, - hooks: allHooks, + return { + type: 'custom_dialog', + component: createElement(HooksDialog, { + hooks: allHooks, + onClose: () => context.ui.removeComponent(), + }), }; - - context.ui.addItem(hooksListItem); } /** @@ -49,7 +56,8 @@ async function enableAction( context: CommandContext, args: string, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -102,7 +110,8 @@ async function disableAction( context: CommandContext, args: string, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -157,7 +166,8 @@ function completeEnabledHookNames( context: CommandContext, partialArg: string, ): string[] { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const hookSystem = config.getHookSystem(); @@ -177,7 +187,8 @@ function completeDisabledHookNames( context: CommandContext, partialArg: string, ): string[] { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const hookSystem = config.getHookSystem(); @@ -203,7 +214,8 @@ function getHookDisplayName(hook: HookRegistryEntry): string { async function enableAllAction( context: CommandContext, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -274,7 +286,8 @@ async function enableAllAction( async function disableAllAction( context: CommandContext, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -343,6 +356,7 @@ const panelCommand: SlashCommand = { altNames: ['list', 'show'], description: 'Display all registered hooks with their status', kind: CommandKind.BUILT_IN, + autoExecute: true, action: panelAction, }; @@ -393,5 +407,5 @@ export const hooksCommand: SlashCommand = { enableAllCommand, disableAllCommand, ], - action: async (context: CommandContext) => panelCommand.action!(context, ''), + action: (context: CommandContext) => panelCommand.action!(context, ''), }; diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 73486e2bf1..2cb880feaa 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -4,8 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { MockInstance } from 'vitest'; -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; import { ideCommand } from './ideCommand.js'; import { type CommandContext } from './types.js'; import { IDE_DEFINITIONS } from '@google/gemini-cli-core'; @@ -53,10 +60,12 @@ describe('ideCommand', () => { settings: { setValue: vi.fn(), }, - config: { - getIdeMode: vi.fn(), - setIdeMode: vi.fn(), - getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + agentContext: { + config: { + getIdeMode: vi.fn(), + setIdeMode: vi.fn(), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + }, }, }, } as unknown as CommandContext; diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 1f726f90e5..df26fdf471 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -217,9 +217,13 @@ export const ideCommand = async (): Promise => { ); // Poll for up to 5 seconds for the extension to activate. for (let i = 0; i < 10; i++) { - await setIdeModeAndSyncConnection(context.services.config!, true, { - logToConsole: false, - }); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + true, + { + logToConsole: false, + }, + ); if ( ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected @@ -262,7 +266,10 @@ export const ideCommand = async (): Promise => { 'ide.enabled', true, ); - await setIdeModeAndSyncConnection(context.services.config!, true); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + true, + ); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { @@ -285,7 +292,10 @@ export const ideCommand = async (): Promise => { 'ide.enabled', false, ); - await setIdeModeAndSyncConnection(context.services.config!, false); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + false, + ); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/initCommand.test.ts b/packages/cli/src/ui/commands/initCommand.test.ts index 62991c7610..0e4f24a1fe 100644 --- a/packages/cli/src/ui/commands/initCommand.test.ts +++ b/packages/cli/src/ui/commands/initCommand.test.ts @@ -31,8 +31,10 @@ describe('initCommand', () => { // Create a fresh mock context for each test mockContext = createMockCommandContext({ services: { - config: { - getTargetDir: () => targetDir, + agentContext: { + config: { + getTargetDir: () => targetDir, + }, }, }, }); @@ -94,7 +96,7 @@ describe('initCommand', () => { // Arrange: Create a context without config const noConfigContext = createMockCommandContext(); if (noConfigContext.services) { - noConfigContext.services.config = null; + noConfigContext.services.agentContext = null; } // Act: Run the command's action diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index ea0d1ea0c6..d4d8040622 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -23,14 +23,14 @@ export const initCommand: SlashCommand = { context: CommandContext, _args: string, ): Promise => { - if (!context.services.config) { + if (!context.services.agentContext?.config) { return { type: 'message', messageType: 'error', content: 'Configuration not available.', }; } - const targetDir = context.services.config.getTargetDir(); + const targetDir = context.services.agentContext.config.getTargetDir(); const geminiMdPath = path.join(targetDir, 'GEMINI.md'); const result = performInit(fs.existsSync(geminiMdPath)); diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index ecce5c9cd5..9a3254fbae 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mcpCommand } from './mcpCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { @@ -77,6 +77,8 @@ describe('mcpCommand', () => { getGeminiClient: ReturnType; getMcpClientManager: ReturnType; getResourceRegistry: ReturnType; + setUserInteractedWithMcp: ReturnType; + getLastMcpError: ReturnType; }; beforeEach(() => { @@ -104,26 +106,36 @@ describe('mcpCommand', () => { }), getGeminiClient: vi.fn(), getMcpClientManager: vi.fn().mockImplementation(() => ({ - getBlockedMcpServers: vi.fn(), - getMcpServers: vi.fn(), + getBlockedMcpServers: vi.fn().mockReturnValue([]), + getMcpServers: vi.fn().mockReturnValue({}), + getLastError: vi.fn().mockReturnValue(undefined), })), getResourceRegistry: vi.fn().mockReturnValue({ getAllResources: vi.fn().mockReturnValue([]), }), + setUserInteractedWithMcp: vi.fn(), + getLastMcpError: vi.fn().mockReturnValue(undefined), }; mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: { + config: mockConfig, + toolRegistry: mockConfig.getToolRegistry(), + }, }, }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('basic functionality', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -137,7 +149,8 @@ describe('mcpCommand', () => { }); it('should show an error if tool registry is not available', async () => { - mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext as any).toolRegistry = undefined; const result = await mcpCommand.action!(mockContext, ''); @@ -161,6 +174,7 @@ describe('mcpCommand', () => { mockConfig.getMcpClientManager = vi.fn().mockReturnValue({ getMcpServers: vi.fn().mockReturnValue(mockMcpServers), getBlockedMcpServers: vi.fn().mockReturnValue([]), + getLastError: vi.fn().mockReturnValue(undefined), }); }); @@ -186,9 +200,13 @@ describe('mcpCommand', () => { ...mockServer3Tools, ]; - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ + const mockToolRegistry = { getAllTools: vi.fn().mockReturnValue(allTools), - }); + }; + mockConfig.getToolRegistry = vi.fn().mockReturnValue(mockToolRegistry); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext as any).toolRegistry = + mockToolRegistry; const resourcesByServer: Record< string, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 6b5e7d120c..0fb6b5a1dd 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -42,8 +42,8 @@ const authCommand: SlashCommand = { args: string, ): Promise => { const serverName = args.trim(); - const { config } = context.services; - + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -52,6 +52,8 @@ const authCommand: SlashCommand = { }; } + config.setUserInteractedWithMcp(); + const mcpServers = config.getMcpClientManager()?.getMcpServers() ?? {}; if (!serverName) { @@ -136,7 +138,7 @@ const authCommand: SlashCommand = { await mcpClientManager.restartServer(serverName); } // Update the client with the new tools - const geminiClient = config.getGeminiClient(); + const geminiClient = context.services.agentContext?.geminiClient; if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } @@ -147,7 +149,7 @@ const authCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `Successfully authenticated and refreshed tools for '${serverName}'.`, + content: `Successfully authenticated and reloaded tools for '${serverName}'`, }; } catch (error) { return { @@ -160,7 +162,8 @@ const authCommand: SlashCommand = { } }, completion: async (context: CommandContext, partialArg: string) => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; @@ -175,7 +178,8 @@ const listAction = async ( showDescriptions = false, showSchema = false, ): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -184,7 +188,9 @@ const listAction = async ( }; } - const toolRegistry = config.getToolRegistry(); + config.setUserInteractedWithMcp(); + + const toolRegistry = agentContext.toolRegistry; if (!toolRegistry) { return { type: 'message', @@ -250,6 +256,13 @@ const listAction = async ( enablementState[serverName] = await enablementManager.getDisplayState(serverName); } + const errors: Record = {}; + for (const serverName of serverNames) { + const error = config.getMcpClientManager()?.getLastError(serverName); + if (error) { + errors[serverName] = error; + } + } const mcpStatusItem: HistoryItemMcpStatus = { type: MessageType.MCP_STATUS, @@ -274,16 +287,19 @@ const listAction = async ( })), authStatus, enablementState, - blockedServers: blockedMcpServers, + errors, + blockedServers: blockedMcpServers.map((s) => ({ + name: s.name, + extensionName: s.extensionName, + })), discoveryInProgress, connectingServers, - showDescriptions, - showSchema, + showDescriptions: Boolean(showDescriptions), + showSchema: Boolean(showSchema), }; context.ui.addItem(mcpStatusItem); }; - const listCommand: SlashCommand = { name: 'list', altNames: ['ls', 'nodesc', 'nodescription'], @@ -311,16 +327,17 @@ const schemaCommand: SlashCommand = { action: (context) => listAction(context, true, true), }; -const refreshCommand: SlashCommand = { - name: 'refresh', - altNames: ['reload'], - description: 'Restarts MCP servers', +const reloadCommand: SlashCommand = { + name: 'reload', + altNames: ['refresh'], + description: 'Reloads MCP servers', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( context: CommandContext, ): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -340,13 +357,13 @@ const refreshCommand: SlashCommand = { context.ui.addItem({ type: 'info', - text: 'Restarting MCP servers...', + text: 'Reloading MCP servers...', }); await mcpClientManager.restart(); // Update the client with the new tools - const geminiClient = config.getGeminiClient(); + const geminiClient = agentContext.geminiClient; if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } @@ -363,7 +380,8 @@ async function handleEnableDisable( args: string, enable: boolean, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -372,6 +390,8 @@ async function handleEnableDisable( }; } + config.setUserInteractedWithMcp(); + const parts = args.trim().split(/\s+/); const isSession = parts.includes('--session'); const serverName = parts.filter((p) => p !== '--session')[0]; @@ -444,13 +464,13 @@ async function handleEnableDisable( const mcpClientManager = config.getMcpClientManager(); if (mcpClientManager) { context.ui.addItem( - { type: 'info', text: 'Restarting MCP servers...' }, + { type: 'info', text: 'Reloading MCP servers...' }, Date.now(), ); await mcpClientManager.restart(); } - if (config.getGeminiClient()?.isInitialized()) - await config.getGeminiClient().setTools(); + if (agentContext.geminiClient?.isInitialized()) + await agentContext.geminiClient.setTools(); context.ui.reloadCommands(); return { type: 'message', messageType: 'info', content: msg }; @@ -461,7 +481,8 @@ async function getEnablementCompletion( partialArg: string, showEnabled: boolean, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const servers = Object.keys( config.getMcpClientManager()?.getMcpServers() || {}, @@ -505,7 +526,7 @@ export const mcpCommand: SlashCommand = { descCommand, schemaCommand, authCommand, - refreshCommand, + reloadCommand, enableCommand, disableCommand, ], diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 1a2c7e3936..f02393bef2 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Mock } from 'vitest'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { memoryCommand } from './memoryCommand.js'; import type { SlashCommand, CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; @@ -39,13 +38,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { type: 'message', messageType: 'info', - content: `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, + content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`, }; } return { type: 'message', messageType: 'info', - content: 'Memory refreshed successfully.', + content: 'Memory reloaded successfully.', }; }), showMemory: vi.fn(), @@ -63,7 +62,7 @@ describe('memoryCommand', () => { let mockContext: CommandContext; const getSubCommand = ( - name: 'show' | 'add' | 'refresh' | 'list', + name: 'show' | 'add' | 'reload' | 'list', ): SlashCommand => { const subCommand = memoryCommand.subCommands?.find( (cmd) => cmd.name === name, @@ -103,10 +102,12 @@ describe('memoryCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getUserMemory: mockGetUserMemory, - getGeminiMdFileCount: mockGetGeminiMdFileCount, - getExtensionLoader: () => new SimpleExtensionLoader([]), + agentContext: { + config: { + getUserMemory: mockGetUserMemory, + getGeminiMdFileCount: mockGetGeminiMdFileCount, + getExtensionLoader: () => new SimpleExtensionLoader([]), + }, }, }, }); @@ -206,15 +207,15 @@ describe('memoryCommand', () => { }); }); - describe('/memory refresh', () => { - let refreshCommand: SlashCommand; + describe('/memory reload', () => { + let reloadCommand: SlashCommand; let mockSetUserMemory: Mock; let mockSetGeminiMdFileCount: Mock; let mockSetGeminiMdFilePaths: Mock; let mockContextManagerRefresh: Mock; beforeEach(() => { - refreshCommand = getSubCommand('refresh'); + reloadCommand = getSubCommand('reload'); mockSetUserMemory = vi.fn(); mockSetGeminiMdFileCount = vi.fn(); mockSetGeminiMdFilePaths = vi.fn(); @@ -251,7 +252,7 @@ describe('memoryCommand', () => { mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: { config: mockConfig }, settings: { merged: { memoryDiscoveryMaxDirs: 1000, @@ -266,17 +267,17 @@ describe('memoryCommand', () => { }); it('should use ContextManager.refresh when JIT is enabled', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + if (!reloadCommand.action) throw new Error('Command has no action'); // Enable JIT in mock config - const config = mockContext.services.config; + const config = mockContext.services.agentContext?.config; if (!config) throw new Error('Config is undefined'); vi.mocked(config.isJitContextEnabled).mockReturnValue(true); vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content'); vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockContextManagerRefresh).toHaveBeenCalledOnce(); expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled(); @@ -284,29 +285,29 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Memory refreshed successfully. Loaded 18 characters from 3 file(s).', + text: 'Memory reloaded successfully. Loaded 18 characters from 3 file(s).', }, expect.any(Number), ); }); - it('should display success message when memory is refreshed with content (Legacy)', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + it('should display success message when memory is reloaded with content (Legacy)', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); const successMessage = { type: 'message', messageType: MessageType.INFO, content: - 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', + 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', }; mockRefreshMemory.mockResolvedValue(successMessage); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: 'Reloading memory from source files...', }, expect.any(Number), ); @@ -316,42 +317,42 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).', + text: 'Memory reloaded successfully. Loaded 18 characters from 2 file(s).', }, expect.any(Number), ); }); - it('should display success message when memory is refreshed with no content', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + it('should display success message when memory is reloaded with no content', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); const successMessage = { type: 'message', messageType: MessageType.INFO, - content: 'Memory refreshed successfully. No memory content found.', + content: 'Memory reloaded successfully. No memory content found.', }; mockRefreshMemory.mockResolvedValue(successMessage); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Memory refreshed successfully. No memory content found.', + text: 'Memory reloaded successfully. No memory content found.', }, expect.any(Number), ); }); - it('should display an error message if refreshing fails', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + it('should display an error message if reloading fails', async () => { + if (!reloadCommand.action) throw new Error('Command has no action'); const error = new Error('Failed to read memory files.'); mockRefreshMemory.mockRejectedValue(error); - await refreshCommand.action(mockContext, ''); + await reloadCommand.action(mockContext, ''); expect(mockRefreshMemory).toHaveBeenCalledOnce(); expect(mockSetUserMemory).not.toHaveBeenCalled(); @@ -361,27 +362,27 @@ describe('memoryCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, - text: `Error refreshing memory: ${error.message}`, + text: `Error reloading memory: ${error.message}`, }, expect.any(Number), ); }); it('should not throw if config service is unavailable', async () => { - if (!refreshCommand.action) throw new Error('Command has no action'); + if (!reloadCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); await expect( - refreshCommand.action(nullConfigContext, ''), + reloadCommand.action(nullConfigContext, ''), ).resolves.toBeUndefined(); expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: 'Reloading memory from source files...', }, expect.any(Number), ); @@ -414,8 +415,10 @@ describe('memoryCommand', () => { }); mockContext = createMockCommandContext({ services: { - config: { - getGeminiMdFilePaths: mockGetGeminiMdfilePaths, + agentContext: { + config: { + getGeminiMdFilePaths: mockGetGeminiMdfilePaths, + }, }, }, }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index a31280f824..145fbae9c3 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -11,8 +11,11 @@ import { showMemory, } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; -import type { SlashCommand, SlashCommandActionReturn } from './types.js'; -import { CommandKind } from './types.js'; +import { + CommandKind, + type SlashCommand, + type SlashCommandActionReturn, +} from './types.js'; export const memoryCommand: SlashCommand = { name: 'memory', @@ -26,7 +29,7 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) return; const result = showMemory(config); @@ -63,22 +66,22 @@ export const memoryCommand: SlashCommand = { }, }, { - name: 'refresh', - altNames: ['reload'], - description: 'Refresh the memory from the source', + name: 'reload', + altNames: ['refresh'], + description: 'Reload the memory from the source', kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { context.ui.addItem( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: 'Reloading memory from source files...', }, Date.now(), ); try { - const config = context.services.config; + const config = context.services.agentContext?.config; if (config) { const result = await refreshMemory(config); @@ -95,7 +98,7 @@ export const memoryCommand: SlashCommand = { { type: MessageType.ERROR, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - text: `Error refreshing memory: ${(error as Error).message}`, + text: `Error reloading memory: ${(error as Error).message}`, }, Date.now(), ); @@ -108,7 +111,7 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) return; const result = listMemoryFiles(config); diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 89938eb037..aa2359d8fa 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -37,8 +37,11 @@ describe('modelCommand', () => { } const mockRefreshUserQuota = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, + get config() { + return this; + }, } as unknown as Config; await modelCommand.action(mockContext, ''); @@ -66,8 +69,11 @@ describe('modelCommand', () => { (c) => c.name === 'manage', ); const mockRefreshUserQuota = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, + get config() { + return this; + }, } as unknown as Config; await manageCommand!.action!(mockContext, ''); @@ -84,7 +90,7 @@ describe('modelCommand', () => { expect(setCommand).toBeDefined(); const mockSetModel = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), @@ -98,6 +104,9 @@ describe('modelCommand', () => { getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), + get config() { + return this; + }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro'); @@ -116,7 +125,7 @@ describe('modelCommand', () => { (c) => c.name === 'set', ); const mockSetModel = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), @@ -130,6 +139,9 @@ describe('modelCommand', () => { getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), + get config() { + return this; + }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro --persist'); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index ead7e521c5..facaba81ba 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -34,10 +34,10 @@ const setModelCommand: SlashCommand = { const modelName = parts[0]; const persist = parts.includes('--persist'); - if (context.services.config) { - context.services.config.setModel(modelName, !persist); + if (context.services.agentContext?.config) { + context.services.agentContext.config.setModel(modelName, !persist); const event = new ModelSlashCommandEvent(modelName); - logModelSlashCommand(context.services.config, event); + logModelSlashCommand(context.services.agentContext.config, event); context.ui.addItem({ type: MessageType.INFO, @@ -53,8 +53,8 @@ const manageModelCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { - if (context.services.config) { - await context.services.config.refreshUserQuota(); + if (context.services.agentContext?.config) { + await context.services.agentContext.config.refreshUserQuota(); } return { type: 'dialog', diff --git a/packages/cli/src/ui/commands/oncallCommand.tsx b/packages/cli/src/ui/commands/oncallCommand.tsx index ba4cbe4835..23236ea49c 100644 --- a/packages/cli/src/ui/commands/oncallCommand.tsx +++ b/packages/cli/src/ui/commands/oncallCommand.tsx @@ -24,7 +24,8 @@ export const oncallCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { throw new Error('Config not available'); } @@ -56,7 +57,8 @@ export const oncallCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { throw new Error('Config not available'); } diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index 2608b44ca9..49c00ce8bd 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -14,7 +14,9 @@ import { coreEvents, processSingleFileContent, type ProcessedFileReadResult, + readFileWithEncoding, } from '@google/gemini-cli-core'; +import { copyToClipboard } from '../utils/commandUtils.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -25,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { emitFeedback: vi.fn(), }, processSingleFileContent: vi.fn(), + readFileWithEncoding: vi.fn(), partToString: vi.fn((val) => val), }; }); @@ -35,23 +38,30 @@ vi.mock('node:path', async (importOriginal) => { ...actual, default: { ...actual }, join: vi.fn((...args) => args.join('/')), + basename: vi.fn((p) => p.split('/').pop()), }; }); +vi.mock('../utils/commandUtils.js', () => ({ + copyToClipboard: vi.fn(), +})); + describe('planCommand', () => { let mockContext: CommandContext; beforeEach(() => { mockContext = createMockCommandContext({ services: { - config: { - isPlanEnabled: vi.fn(), - setApprovalMode: vi.fn(), - getApprovedPlanPath: vi.fn(), - getApprovalMode: vi.fn(), - getFileSystemService: vi.fn(), - storage: { - getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + agentContext: { + config: { + isPlanEnabled: vi.fn(), + setApprovalMode: vi.fn(), + getApprovedPlanPath: vi.fn(), + getApprovalMode: vi.fn(), + getFileSystemService: vi.fn(), + storage: { + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + }, }, }, }, @@ -75,17 +85,19 @@ describe('planCommand', () => { }); it('should switch to plan mode if enabled', async () => { - vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true); - vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue( - undefined, - ); + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(undefined); if (!planCommand.action) throw new Error('Action missing'); await planCommand.action(mockContext, ''); - expect(mockContext.services.config!.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.PLAN, - ); + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', 'Switched to Plan Mode.', @@ -94,10 +106,12 @@ describe('planCommand', () => { it('should display the approved plan from config', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; - vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true); - vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue( - mockPlanPath, - ); + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(mockPlanPath); vi.mocked(processSingleFileContent).mockResolvedValue({ llmContent: '# Approved Plan Content', returnDisplay: '# Approved Plan Content', @@ -115,4 +129,46 @@ describe('planCommand', () => { text: '# Approved Plan Content', }); }); + + describe('copy subcommand', () => { + it('should copy the approved plan to clipboard', async () => { + const mockPlanPath = '/mock/plans/dir/approved-plan.md'; + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(mockPlanPath); + vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content'); + + const copySubCommand = planCommand.subCommands?.find( + (sc) => sc.name === 'copy', + ); + if (!copySubCommand?.action) throw new Error('Copy action missing'); + + await copySubCommand.action(mockContext, ''); + + expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath); + expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content'); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + 'Plan copied to clipboard (approved-plan.md).', + ); + }); + + it('should warn if no approved plan is found', async () => { + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(undefined); + + const copySubCommand = planCommand.subCommands?.find( + (sc) => sc.name === 'copy', + ); + if (!copySubCommand?.action) throw new Error('Copy action missing'); + + await copySubCommand.action(mockContext, ''); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + 'No approved plan found to copy.', + ); + }); + }); }); diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index d9cc6739da..c38d021d90 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -4,24 +4,56 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CommandKind, type SlashCommand } from './types.js'; +import { + type CommandContext, + CommandKind, + type SlashCommand, +} from './types.js'; import { ApprovalMode, coreEvents, debugLogger, processSingleFileContent, partToString, + readFileWithEncoding, } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; import * as path from 'node:path'; +import { copyToClipboard } from '../utils/commandUtils.js'; + +async function copyAction(context: CommandContext) { + const config = context.services.agentContext?.config; + if (!config) { + debugLogger.debug('Plan copy command: config is not available in context'); + return; + } + + const planPath = config.getApprovedPlanPath(); + + if (!planPath) { + coreEvents.emitFeedback('warning', 'No approved plan found to copy.'); + return; + } + + try { + const content = await readFileWithEncoding(planPath); + await copyToClipboard(content); + coreEvents.emitFeedback( + 'info', + `Plan copied to clipboard (${path.basename(planPath)}).`, + ); + } catch (error) { + coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error); + } +} export const planCommand: SlashCommand = { name: 'plan', description: 'Switch to Plan Mode and view current plan', kind: CommandKind.BUILT_IN, - autoExecute: true, + autoExecute: false, action: async (context) => { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { debugLogger.debug('Plan command: config is not available in context'); return; @@ -62,4 +94,13 @@ export const planCommand: SlashCommand = { ); } }, + subCommands: [ + { + name: 'copy', + description: 'Copy the currently approved plan to your clipboard', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: copyAction, + }, + ], }; diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts index 554d5cd53d..929b528290 100644 --- a/packages/cli/src/ui/commands/policiesCommand.test.ts +++ b/packages/cli/src/ui/commands/policiesCommand.test.ts @@ -32,7 +32,7 @@ describe('policiesCommand', () => { describe('list subcommand', () => { it('should show error if config is missing', async () => { - mockContext.services.config = null; + mockContext.services.agentContext = null; const listCommand = policiesCommand.subCommands![0]; await listCommand.action!(mockContext, ''); @@ -50,8 +50,11 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue([]), }; - mockContext.services.config = { + mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -85,8 +88,11 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; - mockContext.services.config = { + mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -110,7 +116,9 @@ describe('policiesCommand', () => { expect(content).toContain( '### Yolo Mode Policies (combined with normal mode policies)', ); - expect(content).toContain('### Plan Mode Policies'); + expect(content).toContain( + '### Plan Mode Policies (combined with normal mode policies)', + ); expect(content).toContain( '**DENY** tool: `dangerousTool` [Priority: 10]', ); @@ -142,8 +150,11 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; - mockContext.services.config = { + mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -153,7 +164,9 @@ describe('policiesCommand', () => { const content = (call[0] as { text: string }).text; // Plan-only rules appear under Plan Mode section - expect(content).toContain('### Plan Mode Policies'); + expect(content).toContain( + '### Plan Mode Policies (combined with normal mode policies)', + ); // glob ALLOW is plan-only, should appear in plan section expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]'); // shell ALLOW has no modes (applies to all), appears in normal section diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts index f4bd13de28..c6f3b1e1e1 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -51,7 +51,8 @@ const listPoliciesCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { context.ui.addItem( { @@ -99,7 +100,10 @@ const listPoliciesCommand: SlashCommand = { 'Yolo Mode Policies (combined with normal mode policies)', uniqueYolo, ); - content += formatSection('Plan Mode Policies', uniquePlan); + content += formatSection( + 'Plan Mode Policies (combined with normal mode policies)', + uniquePlan, + ); context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/privacyCommand.ts b/packages/cli/src/ui/commands/privacyCommand.ts index 4526de500e..cb56b84109 100644 --- a/packages/cli/src/ui/commands/privacyCommand.ts +++ b/packages/cli/src/ui/commands/privacyCommand.ts @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { + CommandKind, + type OpenDialogActionReturn, + type SlashCommand, +} from './types.js'; export const privacyCommand: SlashCommand = { name: 'privacy', diff --git a/packages/cli/src/ui/commands/quitCommand.test.ts b/packages/cli/src/ui/commands/quitCommand.test.ts index dc1f5a01f5..3add9c2183 100644 --- a/packages/cli/src/ui/commands/quitCommand.test.ts +++ b/packages/cli/src/ui/commands/quitCommand.test.ts @@ -26,8 +26,10 @@ describe('quitCommand', () => { it('returns a QuitActionReturn object with the correct messages', () => { const mockContext = createMockCommandContext({ services: { - config: { - getSessionId: () => 'test-session-id', + agentContext: { + config: { + getSessionId: () => 'test-session-id', + }, }, }, session: { diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index 251e4e6311..9207d2fb38 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -29,7 +29,7 @@ export const quitCommand: SlashCommand = { { type: 'quit', duration: formatDuration(wallDuration), - sessionId: context.services.config?.getSessionId(), + sessionId: context.services.agentContext?.config.getSessionId(), id: now, }, ], diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts index 2a5def5c42..a2f29ca5b9 100644 --- a/packages/cli/src/ui/commands/restoreCommand.test.ts +++ b/packages/cli/src/ui/commands/restoreCommand.test.ts @@ -47,14 +47,17 @@ describe('restoreCommand', () => { getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir), getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir), }, - getGeminiClient: vi.fn().mockReturnValue({ + geminiClient: { setHistory: mockSetHistory, - }), + }, + get config() { + return this; + }, } as unknown as Config; mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: mockConfig, git: mockGitService, }, }); diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 3051588e7c..cf18836c20 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -37,10 +37,11 @@ async function restoreAction( args: string, ): Promise { const { services, ui } = context; - const { config, git: gitService } = services; + const { agentContext, git: gitService } = services; const { addItem, loadHistory } = ui; - const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); + const checkpointDir = + agentContext?.config.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return { @@ -116,7 +117,7 @@ async function restoreAction( } else if (action.type === 'load_history' && loadHistory) { loadHistory(action.history); if (action.clientHistory) { - config?.getGeminiClient()?.setHistory(action.clientHistory); + agentContext!.geminiClient?.setHistory(action.clientHistory); } } } @@ -140,8 +141,9 @@ async function completion( _partialArg: string, ): Promise { const { services } = context; - const { config } = services; - const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); + const { agentContext } = services; + const checkpointDir = + agentContext?.config.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return []; } diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts new file mode 100644 index 0000000000..89097e6833 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { resumeCommand } from './resumeCommand.js'; +import type { CommandContext } from './types.js'; + +describe('resumeCommand', () => { + it('should open the session browser for bare /resume', async () => { + const result = await resumeCommand.action?.({} as CommandContext, ''); + expect(result).toEqual({ + type: 'dialog', + dialog: 'sessionBrowser', + }); + }); + + it('should expose unified chat subcommands directly under /resume', () => { + const visibleSubCommandNames = (resumeCommand.subCommands ?? []) + .filter((subCommand) => !subCommand.hidden) + .map((subCommand) => subCommand.name); + + expect(visibleSubCommandNames).toEqual( + expect.arrayContaining(['list', 'save', 'resume', 'delete', 'share']), + ); + }); + + it('should keep a hidden /resume checkpoints compatibility alias', () => { + const checkpoints = resumeCommand.subCommands?.find( + (subCommand) => subCommand.name === 'checkpoints', + ); + expect(checkpoints?.hidden).toBe(true); + expect( + checkpoints?.subCommands?.map((subCommand) => subCommand.name), + ).toEqual( + expect.arrayContaining(['list', 'save', 'resume', 'delete', 'share']), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 636dfef1b6..bbb35a898c 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -10,10 +10,11 @@ import type { SlashCommand, } from './types.js'; import { CommandKind } from './types.js'; +import { chatResumeSubCommands } from './chatCommand.js'; export const resumeCommand: SlashCommand = { name: 'resume', - description: 'Browse and resume auto-saved conversations', + description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, action: async ( @@ -23,4 +24,5 @@ export const resumeCommand: SlashCommand = { type: 'dialog', dialog: 'sessionBrowser', }), + subCommands: chatResumeSubCommands, }; diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index 529991b07f..f878091a45 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -38,6 +38,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, coreEvents: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...actual.coreEvents, emitFeedback: vi.fn(), }, @@ -97,15 +98,17 @@ describe('rewindCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ + agentContext: { + geminiClient: { getChatRecordingService: mockGetChatRecordingService, setHistory: mockSetHistory, sendMessageStream: mockSendMessageStream, - }), - getSessionId: () => 'test-session-id', - getContextManager: () => ({ refresh: mockResetContext }), - getProjectRoot: mockGetProjectRoot, + }, + config: { + getSessionId: () => 'test-session-id', + getContextManager: () => ({ refresh: mockResetContext }), + getProjectRoot: mockGetProjectRoot, + }, }, }, ui: { @@ -293,7 +296,12 @@ describe('rewindCommand', () => { it('should fail if client is not initialized', () => { const context = createMockCommandContext({ services: { - config: { getGeminiClient: () => undefined }, + agentContext: { + geminiClient: undefined, + get config() { + return this; + }, + }, }, }) as unknown as CommandContext; @@ -309,8 +317,11 @@ describe('rewindCommand', () => { it('should fail if recording service is unavailable', () => { const context = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ getChatRecordingService: () => undefined }), + agentContext: { + geminiClient: { getChatRecordingService: () => undefined }, + get config() { + return this; + }, }, }, }) as unknown as CommandContext; diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index c4af3e845d..c4e0284d0f 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -61,7 +61,7 @@ async function rewindConversation( client.setHistory(clientHistory as Content[]); // Reset context manager as we are rewinding history - await context.services.config?.getContextManager()?.refresh(); + await context.services.agentContext?.config.getContextManager()?.refresh(); // Update UI History // We generate IDs based on index for the rewind history @@ -94,7 +94,8 @@ export const rewindCommand: SlashCommand = { description: 'Jump back to a specific message and restart the conversation', kind: CommandKind.BUILT_IN, action: (context) => { - const config = context.services.config; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return { type: 'message', @@ -102,7 +103,7 @@ export const rewindCommand: SlashCommand = { content: 'Config not found', }; - const client = config.getGeminiClient(); + const client = agentContext.geminiClient; if (!client) return { type: 'message', diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts index 91b2c50cc6..48ad6355ca 100644 --- a/packages/cli/src/ui/commands/settingsCommand.ts +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -4,14 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { + CommandKind, + type OpenDialogActionReturn, + type SlashCommand, +} from './types.js'; export const settingsCommand: SlashCommand = { name: 'settings', description: 'View and edit Gemini CLI settings', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'settings', diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 0125ae70bd..9a5b6a8ec1 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -17,8 +17,7 @@ import { } from './setupGithubCommand.js'; import type { CommandContext } from './types.js'; import * as commandUtils from '../utils/commandUtils.js'; -import type { ToolActionReturn } from '@google/gemini-cli-core'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, type ToolActionReturn } from '@google/gemini-cli-core'; vi.mock('child_process'); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index a125b1eda4..afc9b7210e 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -17,8 +17,11 @@ import { getGitHubRepoInfo, } from '../../utils/gitUtils.js'; -import type { SlashCommand, SlashCommandActionReturn } from './types.js'; -import { CommandKind } from './types.js'; +import { + CommandKind, + type SlashCommand, + type SlashCommandActionReturn, +} from './types.js'; import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js'; import { debugLogger } from '@google/gemini-cli-core'; @@ -227,7 +230,7 @@ export const setupGithubCommand: SlashCommand = { } // Get the latest release tag from GitHub - const proxy = context?.services?.config?.getProxy(); + const proxy = context?.services?.agentContext?.config.getProxy(); const releaseTag = await getLatestGitHubRelease(proxy); const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; diff --git a/packages/cli/src/ui/commands/shortcutsCommand.ts b/packages/cli/src/ui/commands/shortcutsCommand.ts index 49dc869e6b..9e1f444426 100644 --- a/packages/cli/src/ui/commands/shortcutsCommand.ts +++ b/packages/cli/src/ui/commands/shortcutsCommand.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { CommandKind, type SlashCommand } from './types.js'; export const shortcutsCommand: SlashCommand = { name: 'shortcuts', diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 89f690e143..120ba01ed7 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -68,7 +68,7 @@ describe('skillsCommand', () => { ]; context = createMockCommandContext({ services: { - config: { + agentContext: { getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue(skills), getSkills: vi.fn().mockReturnValue(skills), @@ -80,6 +80,9 @@ describe('skillsCommand', () => { ), }), getContentGenerator: vi.fn(), + get config() { + return this; + }, } as unknown as Config, settings: { merged: createTestMergedSettings({ skills: { disabled: [] } }), @@ -162,7 +165,8 @@ describe('skillsCommand', () => { }); it('should filter built-in skills by default and show them with "all"', async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'regular', @@ -452,7 +456,8 @@ describe('skillsCommand', () => { }); it('should show error if skills are disabled by admin during disable', async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const disableCmd = skillsCommand.subCommands!.find( @@ -470,7 +475,8 @@ describe('skillsCommand', () => { }); it('should show error if skills are disabled by admin during enable', async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const enableCmd = skillsCommand.subCommands!.find( @@ -497,8 +503,7 @@ describe('skillsCommand', () => { const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 200)); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); @@ -537,15 +542,15 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, { name: 'skill2' }, { name: 'skill3' }, ] as SkillDefinition[]); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -562,13 +567,13 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, ] as SkillDefinition[]); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -585,14 +590,14 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill2' }, // skill1 removed, skill3 added { name: 'skill3' }, ] as SkillDefinition[]); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -608,7 +613,7 @@ describe('skillsCommand', () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; - context.services.config = null; + context.services.agentContext = null; await reloadCmd.action!(context, ''); @@ -628,8 +633,7 @@ describe('skillsCommand', () => { const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((_, reject) => setTimeout(() => reject(error), 200)); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); await vi.advanceTimersByTimeAsync(100); @@ -651,7 +655,8 @@ describe('skillsCommand', () => { const disableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'disable', )!; - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'skill1', @@ -681,7 +686,8 @@ describe('skillsCommand', () => { const enableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'enable', )!; - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'skill1', diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 714f206f36..a1f9c82445 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -16,9 +16,8 @@ import { MessageType, } from '../types.js'; import { disableSkill, enableSkill } from '../../utils/skillSettings.js'; -import { getErrorMessage } from '../../utils/errors.js'; -import { getAdminErrorMessage } from '@google/gemini-cli-core'; +import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core'; import { linkSkill, renderSkillActionFeedback, @@ -47,7 +46,7 @@ async function listAction( } } - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { context.ui.addItem({ type: MessageType.ERROR, @@ -128,8 +127,8 @@ async function linkAction( text: `Successfully linked skills from "${sourcePath}" (${scope}).`, }); - if (context.services.config) { - await context.services.config.reloadSkills(); + if (context.services.agentContext?.config) { + await context.services.agentContext.config.reloadSkills(); } } catch (error) { context.ui.addItem({ @@ -151,14 +150,14 @@ async function disableAction( }); return; } - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', - context.services.config ?? undefined, + context.services.agentContext?.config ?? undefined, ), }, Date.now(), @@ -212,14 +211,14 @@ async function enableAction( return; } - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', - context.services.config ?? undefined, + context.services.agentContext?.config ?? undefined, ), }, Date.now(), @@ -247,7 +246,7 @@ async function enableAction( async function reloadAction( context: CommandContext, ): Promise { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { context.ui.addItem({ type: MessageType.ERROR, @@ -334,7 +333,7 @@ function disableCompletion( context: CommandContext, partialArg: string, ): string[] { - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { return []; } @@ -348,7 +347,7 @@ function enableCompletion( context: CommandContext, partialArg: string, ): string[] { - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { return []; } diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 63fe3eb9e5..86ecf68654 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -20,6 +20,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { UserAccountManager: vi.fn().mockImplementation(() => ({ getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'), })), + getG1CreditBalance: vi.fn().mockReturnValue(undefined), }; }); @@ -39,11 +40,21 @@ describe('statsCommand', () => { mockContext.session.stats.sessionStartTime = startTime; }); - it('should display general session stats when run with no subcommand', () => { + it('should display general session stats when run with no subcommand', async () => { if (!statsCommand.action) throw new Error('Command has no action'); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - statsCommand.action(mockContext, ''); + mockContext.services.agentContext = { + refreshUserQuota: vi.fn(), + refreshAvailableCredits: vi.fn(), + getUserTierName: vi.fn(), + getUserPaidTier: vi.fn(), + getModel: vi.fn(), + get config() { + return this; + }, + } as unknown as Config; + + await statsCommand.action(mockContext, ''); const expectedDuration = formatDuration( endTime.getTime() - startTime.getTime(), @@ -55,6 +66,7 @@ describe('statsCommand', () => { tier: undefined, userEmail: 'mock@example.com', currentModel: undefined, + creditBalance: undefined, }); }); @@ -71,13 +83,18 @@ describe('statsCommand', () => { .fn() .mockReturnValue('2025-01-01T12:00:00Z'); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, getUserTierName: mockGetUserTierName, getModel: mockGetModel, getQuotaRemaining: mockGetQuotaRemaining, getQuotaLimit: mockGetQuotaLimit, getQuotaResetTime: mockGetQuotaResetTime, + getUserPaidTier: vi.fn(), + refreshAvailableCredits: vi.fn(), + get config() { + return this; + }, } as unknown as Config; await statsCommand.action(mockContext, ''); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index b90e7309e1..2ca4596337 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -11,7 +11,10 @@ import type { } from '../types.js'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; -import { UserAccountManager } from '@google/gemini-cli-core'; +import { + UserAccountManager, + getG1CreditBalance, +} from '@google/gemini-cli-core'; import { type CommandContext, type SlashCommand, @@ -26,9 +29,11 @@ function getUserIdentity(context: CommandContext) { const cachedAccount = userAccountManager.getCachedGoogleAccount(); const userEmail = cachedAccount ?? undefined; - const tier = context.services.config?.getUserTierName(); + const tier = context.services.agentContext?.config.getUserTierName(); + const paidTier = context.services.agentContext?.config.getUserPaidTier(); + const creditBalance = getG1CreditBalance(paidTier) ?? undefined; - return { selectedAuthType, userEmail, tier }; + return { selectedAuthType, userEmail, tier, creditBalance }; } async function defaultSessionView(context: CommandContext) { @@ -43,8 +48,9 @@ async function defaultSessionView(context: CommandContext) { } const wallDuration = now.getTime() - sessionStartTime.getTime(); - const { selectedAuthType, userEmail, tier } = getUserIdentity(context); - const currentModel = context.services.config?.getModel(); + const { selectedAuthType, userEmail, tier, creditBalance } = + getUserIdentity(context); + const currentModel = context.services.agentContext?.config.getModel(); const statsItem: HistoryItemStats = { type: MessageType.STATS, @@ -53,15 +59,22 @@ async function defaultSessionView(context: CommandContext) { userEmail, tier, currentModel, + creditBalance, }; - if (context.services.config) { - const quota = await context.services.config.refreshUserQuota(); + if (context.services.agentContext?.config) { + const [quota] = await Promise.all([ + context.services.agentContext.config.refreshUserQuota(), + context.services.agentContext.config.refreshAvailableCredits(), + ]); if (quota) { statsItem.quotas = quota; - statsItem.pooledRemaining = context.services.config.getQuotaRemaining(); - statsItem.pooledLimit = context.services.config.getQuotaLimit(); - statsItem.pooledResetTime = context.services.config.getQuotaResetTime(); + statsItem.pooledRemaining = + context.services.agentContext.config.getQuotaRemaining(); + statsItem.pooledLimit = + context.services.agentContext.config.getQuotaLimit(); + statsItem.pooledResetTime = + context.services.agentContext.config.getQuotaResetTime(); } } @@ -74,6 +87,7 @@ export const statsCommand: SlashCommand = { description: 'Check session stats. Usage: /stats [session|model|tools]', kind: CommandKind.BUILT_IN, autoExecute: false, + isSafeConcurrent: true, action: async (context: CommandContext) => { await defaultSessionView(context); }, @@ -83,6 +97,7 @@ export const statsCommand: SlashCommand = { description: 'Show session-specific usage statistics', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: async (context: CommandContext) => { await defaultSessionView(context); }, @@ -92,12 +107,16 @@ export const statsCommand: SlashCommand = { description: 'Show model-specific usage statistics', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: (context: CommandContext) => { const { selectedAuthType, userEmail, tier } = getUserIdentity(context); - const currentModel = context.services.config?.getModel(); - const pooledRemaining = context.services.config?.getQuotaRemaining(); - const pooledLimit = context.services.config?.getQuotaLimit(); - const pooledResetTime = context.services.config?.getQuotaResetTime(); + const currentModel = context.services.agentContext?.config.getModel(); + const pooledRemaining = + context.services.agentContext?.config.getQuotaRemaining(); + const pooledLimit = + context.services.agentContext?.config.getQuotaLimit(); + const pooledResetTime = + context.services.agentContext?.config.getQuotaResetTime(); context.ui.addItem({ type: MessageType.MODEL_STATS, selectedAuthType, @@ -115,6 +134,7 @@ export const statsCommand: SlashCommand = { description: 'Show tool-specific usage statistics', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: (context: CommandContext) => { context.ui.addItem({ type: MessageType.TOOL_STATS, diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts index 780513ab6c..64a4fb5057 100644 --- a/packages/cli/src/ui/commands/terminalSetupCommand.ts +++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { CommandKind, type SlashCommand } from './types.js'; import { terminalSetup } from '../utils/terminalSetup.js'; import { type MessageActionReturn } from '@google/gemini-cli-core'; diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts index 4b72625d55..265aaf9a75 100644 --- a/packages/cli/src/ui/commands/themeCommand.ts +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { + CommandKind, + type OpenDialogActionReturn, + type SlashCommand, +} from './types.js'; export const themeCommand: SlashCommand = { name: 'theme', diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts index 257e6ba167..02d9ddb5bc 100644 --- a/packages/cli/src/ui/commands/toolsCommand.test.ts +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { vi } from 'vitest'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, type vi } from 'vitest'; import { toolsCommand } from './toolsCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; @@ -31,8 +30,8 @@ describe('toolsCommand', () => { it('should display an error if the tool registry is unavailable', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => undefined, + agentContext: { + toolRegistry: undefined, }, }, }); @@ -49,10 +48,10 @@ describe('toolsCommand', () => { it('should display "No tools available" when none are found', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ + agentContext: { + toolRegistry: { getAllTools: () => [] as Array>, - }), + }, }, }, }); @@ -67,11 +66,11 @@ describe('toolsCommand', () => { }); }); - it('should list tools without descriptions by default', async () => { + it('should list tools without descriptions by default (no args)', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -88,11 +87,32 @@ describe('toolsCommand', () => { expect(message.tools[1].displayName).toBe('Code Editor'); }); + it('should list tools without descriptions when "list" arg is passed', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + if (!toolsCommand.action) throw new Error('Action not defined'); + await toolsCommand.action(mockContext, 'list'); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.type).toBe(MessageType.TOOLS_LIST); + expect(message.showDescriptions).toBe(false); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); + expect(message.tools[1].displayName).toBe('Code Editor'); + }); + it('should list tools with descriptions when "desc" arg is passed', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -105,9 +125,89 @@ describe('toolsCommand', () => { expect(message.type).toBe(MessageType.TOOLS_LIST); expect(message.showDescriptions).toBe(true); expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); expect(message.tools[0].description).toBe( 'Reads files from the local system.', ); + expect(message.tools[1].displayName).toBe('Code Editor'); expect(message.tools[1].description).toBe('Edits code files.'); }); + + it('should have "list" and "desc" subcommands', () => { + expect(toolsCommand.subCommands).toBeDefined(); + const names = toolsCommand.subCommands?.map((s) => s.name); + expect(names).toContain('list'); + expect(names).toContain('desc'); + expect(names).not.toContain('descriptions'); + }); + + it('subcommand "list" should display tools without descriptions', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + const listCmd = toolsCommand.subCommands?.find((s) => s.name === 'list'); + if (!listCmd?.action) throw new Error('Action not defined'); + await listCmd.action(mockContext, ''); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.showDescriptions).toBe(false); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); + expect(message.tools[1].displayName).toBe('Code Editor'); + }); + + it('subcommand "desc" should display tools with descriptions', async () => { + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + const descCmd = toolsCommand.subCommands?.find((s) => s.name === 'desc'); + if (!descCmd?.action) throw new Error('Action not defined'); + await descCmd.action(mockContext, ''); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.showDescriptions).toBe(true); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); + expect(message.tools[0].description).toBe( + 'Reads files from the local system.', + ); + expect(message.tools[1].displayName).toBe('Code Editor'); + expect(message.tools[1].description).toBe('Edits code files.'); + }); + + it('should expose a desc subcommand for TUI discoverability', async () => { + const descSubCommand = toolsCommand.subCommands?.find( + (cmd) => cmd.name === 'desc', + ); + expect(descSubCommand).toBeDefined(); + expect(descSubCommand?.description).toContain('descriptions'); + + const mockContext = createMockCommandContext({ + services: { + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, + }, + }, + }); + + if (!descSubCommand?.action) throw new Error('Action not defined'); + await descSubCommand.action(mockContext, ''); + + const [message] = (mockContext.ui.addItem as ReturnType).mock + .calls[0]; + expect(message.type).toBe(MessageType.TOOLS_LIST); + expect(message.showDescriptions).toBe(true); + }); }); diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index ff772c5cc8..d3e5aef74b 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -11,43 +11,69 @@ import { } from './types.js'; import { MessageType, type HistoryItemToolsList } from '../types.js'; +async function listTools( + context: CommandContext, + showDescriptions: boolean, +): Promise { + const toolRegistry = context.services.agentContext?.toolRegistry; + if (!toolRegistry) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve tool registry.', + }); + return; + } + + const tools = toolRegistry.getAllTools(); + // Filter out MCP tools by checking for the absence of a serverName property + const geminiTools = tools.filter((tool) => !('serverName' in tool)); + + const toolsListItem: HistoryItemToolsList = { + type: MessageType.TOOLS_LIST, + tools: geminiTools.map((tool) => ({ + name: tool.name, + displayName: tool.displayName, + description: tool.description, + })), + showDescriptions, + }; + + context.ui.addItem(toolsListItem); +} + +const listSubCommand: SlashCommand = { + name: 'list', + description: 'List available Gemini CLI tools.', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext): Promise => + listTools(context, false), +}; + +const descSubCommand: SlashCommand = { + name: 'desc', + altNames: ['descriptions'], + description: 'List available Gemini CLI tools with descriptions.', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext): Promise => + listTools(context, true), +}; + export const toolsCommand: SlashCommand = { name: 'tools', - description: 'List available Gemini CLI tools. Usage: /tools [desc]', + description: + 'List available Gemini CLI tools. Use /tools desc to include descriptions.', kind: CommandKind.BUILT_IN, autoExecute: false, + subCommands: [listSubCommand, descSubCommand], action: async (context: CommandContext, args?: string): Promise => { const subCommand = args?.trim(); - // Default to NOT showing descriptions. The user must opt in with an argument. - let useShowDescriptions = false; - if (subCommand === 'desc' || subCommand === 'descriptions') { - useShowDescriptions = true; - } + // Keep backward compatibility for typed arguments while exposing subcommands in TUI. + const useShowDescriptions = + subCommand === 'desc' || subCommand === 'descriptions'; - const toolRegistry = context.services.config?.getToolRegistry(); - if (!toolRegistry) { - context.ui.addItem({ - type: MessageType.ERROR, - text: 'Could not retrieve tool registry.', - }); - return; - } - - const tools = toolRegistry.getAllTools(); - // Filter out MCP tools by checking for the absence of a serverName property - const geminiTools = tools.filter((tool) => !('serverName' in tool)); - - const toolsListItem: HistoryItemToolsList = { - type: MessageType.TOOLS_LIST, - tools: geminiTools.map((tool) => ({ - name: tool.name, - displayName: tool.displayName, - description: tool.description, - })), - showDescriptions: useShowDescriptions, - }; - - context.ui.addItem(toolsListItem); + await listTools(context, useShowDescriptions); }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2cbb9da9a7..4065e075bf 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -11,11 +11,11 @@ import type { ConfirmationRequest, } from '../types.js'; import type { - Config, GitService, Logger, CommandActionReturn, AgentDefinition, + AgentLoopContext, } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; @@ -39,7 +39,7 @@ export interface CommandContext { // Core services and configuration services: { // TODO(abhipatel12): Ensure that config is never null. - config: Config | null; + agentContext: AgentLoopContext | null; settings: LoadedSettings; git: GitService | undefined; logger: Logger; @@ -177,9 +177,12 @@ export type SlashCommandActionReturn = export enum CommandKind { BUILT_IN = 'built-in', - FILE = 'file', + USER_FILE = 'user-file', + WORKSPACE_FILE = 'workspace-file', + EXTENSION_FILE = 'extension-file', MCP_PROMPT = 'mcp-prompt', AGENT = 'agent', + SKILL = 'skill', } // The standardized contract for any command in the system. @@ -188,6 +191,11 @@ export interface SlashCommand { altNames?: string[]; description: string; hidden?: boolean; + /** + * Optional grouping label for slash completion UI sections. + * Commands with the same label are rendered under one separator. + */ + suggestionGroup?: string; kind: CommandKind; @@ -199,10 +207,18 @@ export interface SlashCommand { */ autoExecute?: boolean; + /** + * Whether this command can be safely executed while the agent is busy (e.g. streaming a response). + */ + isSafeConcurrent?: boolean; + // Optional metadata for extension commands extensionName?: string; extensionId?: string; + // Optional metadata for MCP commands + mcpServerName?: string; + // The action to run. Optional for parent commands that only group sub-commands. action?: ( context: CommandContext, @@ -212,7 +228,7 @@ export interface SlashCommand { | SlashCommandActionReturn | Promise; - // Provides argument completion (e.g., completing a tag for `/chat resume `). + // Provides argument completion (e.g., completing a tag for `/resume resume `). completion?: ( context: CommandContext, partialArg: string, diff --git a/packages/cli/src/ui/commands/upgradeCommand.test.ts b/packages/cli/src/ui/commands/upgradeCommand.test.ts new file mode 100644 index 0000000000..bb07c1bd44 --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.test.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { upgradeCommand } from './upgradeCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { + AuthType, + openBrowserSecurely, + shouldLaunchBrowser, + UPGRADE_URL_PAGE, +} from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + openBrowserSecurely: vi.fn(), + shouldLaunchBrowser: vi.fn().mockReturnValue(true), + UPGRADE_URL_PAGE: 'https://goo.gle/set-up-gemini-code-assist', + }; +}); + +describe('upgradeCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + getUserTierName: vi.fn().mockReturnValue(undefined), + }, + }, + }, + } as unknown as CommandContext); + }); + + it('should have the correct name and description', () => { + expect(upgradeCommand.name).toBe('upgrade'); + expect(upgradeCommand.description).toBe( + 'Upgrade your Gemini Code Assist tier for higher limits', + ); + }); + + it('should call openBrowserSecurely with UPGRADE_URL_PAGE when logged in with Google', async () => { + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + await upgradeCommand.action(mockContext, ''); + + expect(openBrowserSecurely).toHaveBeenCalledWith(UPGRADE_URL_PAGE); + }); + + it('should return an error message when NOT logged in with Google', async () => { + vi.mocked( + mockContext.services.agentContext!.config.getContentGeneratorConfig, + ).mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'The /upgrade command is only available when logged in with Google.', + }); + expect(openBrowserSecurely).not.toHaveBeenCalled(); + }); + + it('should return an error message if openBrowserSecurely fails', async () => { + vi.mocked(openBrowserSecurely).mockRejectedValue( + new Error('Failed to open'), + ); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to open upgrade page: Failed to open', + }); + }); + + it('should return URL message when shouldLaunchBrowser returns false', async () => { + vi.mocked(shouldLaunchBrowser).mockReturnValue(false); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`, + }); + expect(openBrowserSecurely).not.toHaveBeenCalled(); + }); + + it('should return info message for ultra tiers', async () => { + vi.mocked( + mockContext.services.agentContext!.config.getUserTierName, + ).mockReturnValue('Advanced Ultra'); + + if (!upgradeCommand.action) { + throw new Error('The upgrade command must have an action.'); + } + + const result = await upgradeCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'You are already on the highest tier: Advanced Ultra.', + }); + expect(openBrowserSecurely).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/commands/upgradeCommand.ts b/packages/cli/src/ui/commands/upgradeCommand.ts new file mode 100644 index 0000000000..f7c09a42f0 --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + openBrowserSecurely, + shouldLaunchBrowser, + UPGRADE_URL_PAGE, +} from '@google/gemini-cli-core'; +import { isUltraTier } from '../../utils/tierUtils.js'; +import { CommandKind, type SlashCommand } from './types.js'; + +/** + * Command to open the upgrade page for Gemini Code Assist. + * Only intended to be shown/available when the user is logged in with Google. + */ +export const upgradeCommand: SlashCommand = { + name: 'upgrade', + kind: CommandKind.BUILT_IN, + description: 'Upgrade your Gemini Code Assist tier for higher limits', + autoExecute: true, + action: async (context) => { + const config = context.services.agentContext?.config; + const authType = config?.getContentGeneratorConfig()?.authType; + if (authType !== AuthType.LOGIN_WITH_GOOGLE) { + // This command should ideally be hidden if not logged in with Google, + // but we add a safety check here just in case. + return { + type: 'message', + messageType: 'error', + content: + 'The /upgrade command is only available when logged in with Google.', + }; + } + + const tierName = config?.getUserTierName(); + if (isUltraTier(tierName)) { + return { + type: 'message', + messageType: 'info', + content: `You are already on the highest tier: ${tierName}.`, + }; + } + + if (!shouldLaunchBrowser()) { + return { + type: 'message', + messageType: 'info', + content: `Please open this URL in a browser: ${UPGRADE_URL_PAGE}`, + }; + } + + try { + await openBrowserSecurely(UPGRADE_URL_PAGE); + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to open upgrade page: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + return undefined; + }, +}; diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts index 972a230d35..74d54ee5ef 100644 --- a/packages/cli/src/ui/commands/vimCommand.ts +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; +import { CommandKind, type SlashCommand } from './types.js'; export const vimCommand: SlashCommand = { name: 'vim', description: 'Toggle vim mode on/off', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: async (context, _args) => { const newVimState = await context.ui.toggleVimEnabled(); diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx index b7a615a18f..9115ca31c1 100644 --- a/packages/cli/src/ui/components/AboutBox.test.tsx +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -25,10 +25,9 @@ describe('AboutBox', () => { }; it('renders with required props', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('About Gemini CLI'); expect(output).toContain('1.0.0'); @@ -36,7 +35,7 @@ describe('AboutBox', () => { expect(output).toContain('gemini-pro'); expect(output).toContain('default'); expect(output).toContain('macOS'); - expect(output).toContain('Logged in with Google'); + expect(output).toContain('Signed in with Google'); unmount(); }); @@ -46,10 +45,9 @@ describe('AboutBox', () => { ['tier', 'Enterprise', 'Tier'], ])('renders optional prop %s', async (prop, value, label) => { const props = { ...defaultProps, [prop]: value }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain(label); expect(output).toContain(value); @@ -58,21 +56,19 @@ describe('AboutBox', () => { it('renders Auth Method with email when userEmail is provided', async () => { const props = { ...defaultProps, userEmail: 'test@example.com' }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('Logged in with Google (test@example.com)'); + expect(output).toContain('Signed in with Google (test@example.com)'); unmount(); }); it('renders Auth Method correctly when not oauth', async () => { const props = { ...defaultProps, selectedAuthType: 'api-key' }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('api-key'); unmount(); diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx index 7ea744b0fe..aa5fd44c57 100644 --- a/packages/cli/src/ui/components/AboutBox.tsx +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -116,8 +116,8 @@ export const AboutBox: React.FC = ({ {selectedAuthType.startsWith('oauth') ? userEmail - ? `Logged in with Google (${userEmail})` - : 'Logged in with Google' + ? `Signed in with Google (${userEmail})` + : 'Signed in with Google' : selectedAuthType} diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx index 0cfe00c764..76a36fe4dc 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx @@ -17,15 +17,14 @@ describe('AdminSettingsChangedDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('restarts on "r" key press', async () => { - const { stdin, waitUntilReady } = renderWithProviders( + const { stdin } = await renderWithProviders( , { uiActions: { @@ -33,7 +32,6 @@ describe('AdminSettingsChangedDialog', () => { }, }, ); - await waitUntilReady(); act(() => { stdin.write('r'); @@ -43,7 +41,7 @@ describe('AdminSettingsChangedDialog', () => { }); it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { - const { stdin, waitUntilReady } = renderWithProviders( + const { stdin } = await renderWithProviders( , { uiActions: { @@ -51,7 +49,6 @@ describe('AdminSettingsChangedDialog', () => { }, }, ); - await waitUntilReady(); act(() => { stdin.write(key); diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx index b697dc17c4..dda4141294 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx @@ -8,9 +8,11 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; -import { Command, keyMatchers } from '../keyMatchers.js'; +import { Command } from '../key/keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export const AdminSettingsChangedDialog = () => { + const keyMatchers = useKeyMatchers(); const { handleRestart } = useUIActions(); useKeypress( diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx index 05cd4a47f5..2c6ea454db 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -4,21 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { AgentConfigDialog } from './AgentConfigDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import type { AgentDefinition } from '@google/gemini-cli-core'; -vi.mock('../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - mainAreaWidth: 100, - }), -})); - enum TerminalKeys { ENTER = '\u000D', TAB = '\t', @@ -122,19 +115,17 @@ describe('AgentConfigDialog', () => { settings: LoadedSettings, definition: AgentDefinition = createMockAgentDefinition(), ) => { - const result = render( - - - , + const result = await renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, ); - await result.waitUntilReady(); return result; }; @@ -327,5 +318,30 @@ describe('AgentConfigDialog', () => { expect(frame).toContain('false'); unmount(); }); + it('should respond to availableTerminalHeight and truncate list', async () => { + const settings = createMockSettings(); + // Agent config has about 6 base items + 2 per tool + // Render with very small height (20) + const { lastFrame, unmount } = await renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, + ); + await waitFor(() => + expect(lastFrame()).toContain('Configure: Test Agent'), + ); + + const frame = lastFrame(); + // At height 20, it should be heavily truncated and show '▼' + expect(frame).toContain('▼'); + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx index 5b4eb1e912..3f5d348a45 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx @@ -8,17 +8,18 @@ import type React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import type { - LoadableSettingScope, - LoadedSettings, +import { + SettingScope, + type LoadableSettingScope, + type LoadedSettings, } from '../../config/settings.js'; -import { SettingScope } from '../../config/settings.js'; import type { AgentDefinition, AgentOverride } from '@google/gemini-cli-core'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { BaseSettingsDialog, type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; +import { getNestedValue, isRecord } from '../../utils/settingsUtils.js'; /** * Configuration field definition for agent settings @@ -109,34 +110,16 @@ interface AgentConfigDialogProps { settings: LoadedSettings; onClose: () => void; onSave?: () => void; -} - -/** - * Get a nested value from an object using a path array - */ -function getNestedValue( - obj: Record | undefined, - path: string[], -): unknown { - if (!obj) return undefined; - let current: unknown = obj; - for (const key of path) { - if (current === null || current === undefined) return undefined; - if (typeof current !== 'object') return undefined; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current = (current as Record)[key]; - } - return current; + /** Available terminal height for dynamic windowing */ + availableTerminalHeight?: number; } /** * Set a nested value in an object using a path array, creating intermediate objects as needed */ -function setNestedValue( - obj: Record, - path: string[], - value: unknown, -): Record { +function setNestedValue(obj: unknown, path: string[], value: unknown): unknown { + if (!isRecord(obj)) return obj; + const result = { ...obj }; let current = result; @@ -144,12 +127,17 @@ function setNestedValue( const key = path[i]; if (current[key] === undefined || current[key] === null) { current[key] = {}; - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current[key] = { ...(current[key] as Record) }; + } else if (isRecord(current[key])) { + current[key] = { ...current[key] }; + } + + const next = current[key]; + if (isRecord(next)) { + current = next; + } else { + // Cannot traverse further through non-objects + return result; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current = current[key] as Record; } const finalKey = path[path.length - 1]; @@ -206,6 +194,7 @@ export function AgentConfigDialog({ settings, onClose, onSave, + availableTerminalHeight, }: AgentConfigDialogProps): React.JSX.Element { // Scope selector state (User by default) const [selectedScope, setSelectedScope] = useState( @@ -267,11 +256,7 @@ export function AgentConfigDialog({ const items: SettingsDialogItem[] = useMemo( () => AGENT_CONFIG_FIELDS.map((field) => { - const currentValue = getNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, - field.path, - ); + const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; @@ -324,23 +309,18 @@ export function AgentConfigDialog({ const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); if (!field || field.type !== 'boolean') return; - const currentValue = getNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, - field.path, - ); + const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; const newValue = !effectiveValue; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, newValue, ) as AgentOverride; - setPendingOverride(newOverride); setModifiedFields((prev) => new Set(prev).add(key)); @@ -375,9 +355,9 @@ export function AgentConfigDialog({ } // Update pending override locally + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, parsed, ) as AgentOverride; @@ -398,9 +378,9 @@ export function AgentConfigDialog({ if (!field) return; // Remove the override (set to undefined) + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, undefined, ) as AgentOverride; @@ -418,12 +398,6 @@ export function AgentConfigDialog({ [pendingOverride, saveFieldValue], ); - // Footer content - const footerContent = - modifiedFields.size > 0 ? ( - Changes saved automatically. - ) : null; - return ( 0 + ? { + content: ( + + Changes saved automatically. + + ), + height: 1, + } + : undefined + } /> ); } diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx index 6e9623a8ff..571e0d36d3 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -108,7 +108,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with active and pending tool messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -118,14 +118,13 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_and_pending'); unmount(); }); it('renders with empty history and no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -135,14 +134,13 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('empty'); unmount(); }); it('renders with history but no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -152,14 +150,13 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_no_pending'); unmount(); }); it('renders with pending items but no history', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -169,7 +166,6 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_pending_no_history'); unmount(); }); @@ -195,7 +191,7 @@ describe('AlternateBufferQuittingDisplay', () => { ], }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -205,7 +201,6 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Action Required (was prompted):'); expect(output).toContain('confirming_tool'); @@ -220,7 +215,7 @@ describe('AlternateBufferQuittingDisplay', () => { { id: 1, type: 'user', text: 'Hello Gemini' }, { id: 2, type: 'gemini', text: 'Hello User!' }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -230,7 +225,6 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages'); unmount(); }); diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index ac824fefe6..758361be0a 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -29,10 +29,9 @@ describe('', () => { createAnsiToken({ text: 'world!' }), ], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame().trim()).toBe('Hello, world!'); unmount(); }); @@ -47,10 +46,9 @@ describe('', () => { { style: { inverse: true }, text: 'Inverse' }, ])('correctly applies style $text', async ({ style, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...style })]]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); @@ -61,10 +59,9 @@ describe('', () => { { color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' }, ])('correctly applies color $text', async ({ color, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...color })]]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); @@ -76,10 +73,9 @@ describe('', () => { [createAnsiToken({ text: 'Third line' })], [createAnsiToken({ text: '' })], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toBeDefined(); const lines = output.split('\n'); @@ -96,10 +92,9 @@ describe('', () => { [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); @@ -115,10 +110,9 @@ describe('', () => { [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); @@ -135,7 +129,7 @@ describe('', () => { [createAnsiToken({ text: 'Line 4' })], ]; // availableTerminalHeight=3, maxLines=2 => show 2 lines - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { width={80} />, ); - await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); @@ -156,10 +149,9 @@ describe('', () => { for (let i = 0; i < 1000; i++) { largeData.push([createAnsiToken({ text: `Line ${i}` })]); } - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); // We are just checking that it renders something without crashing. expect(lastFrame()).toBeDefined(); unmount(); diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index cc17b6b6b0..a1b30b0856 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -35,7 +35,11 @@ export const AnsiOutputText: React.FC = ({ ? Math.min(availableHeightLimit, maxLines) : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); - const lastLines = disableTruncation ? data : data.slice(-numLinesRetained); + const lastLines = disableTruncation + ? data + : numLinesRetained === 0 + ? [] + : data.slice(-numLinesRetained); return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 9bf821febc..8ff4caaacf 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -10,7 +10,6 @@ import { } from '../../test-utils/render.js'; import { AppHeader } from './AppHeader.js'; import { describe, it, expect, vi } from 'vitest'; -import { makeFakeConfig } from '@google/gemini-cli-core'; import crypto from 'node:crypto'; vi.mock('../utils/terminalSetup.js', () => ({ @@ -19,7 +18,6 @@ vi.mock('../utils/terminalSetup.js', () => ({ describe('', () => { it('should render the banner with default text', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -29,14 +27,12 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); @@ -44,7 +40,6 @@ describe('', () => { }); it('should render the banner with warning text', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -54,14 +49,12 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('There are capacity issues'); expect(lastFrame()).toMatchSnapshot(); @@ -69,7 +62,6 @@ describe('', () => { }); it('should not render the banner when no flags are set', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -78,14 +70,12 @@ describe('', () => { }, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('Banner'); expect(lastFrame()).toMatchSnapshot(); @@ -93,7 +83,6 @@ describe('', () => { }); it('should not render the default banner if shown count is 5 or more', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -111,14 +100,12 @@ describe('', () => { }, }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); @@ -126,7 +113,6 @@ describe('', () => { }); it('should increment the version count when default banner is displayed', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -139,14 +125,12 @@ describe('', () => { // and interfering with the expected persistentState.set call. persistentStateMock.setData({ tipsShown: 10 }); - const { waitUntilReady, unmount } = renderWithProviders( + const { unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); - await waitUntilReady(); expect(persistentStateMock.set).toHaveBeenCalledWith( 'defaultBannerShownCount', @@ -161,7 +145,6 @@ describe('', () => { }); it('should render banner text with unescaped newlines', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -171,21 +154,18 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('First line\\nSecond line'); unmount(); }); it('should render Tips when tipsShown is less than 10', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -197,14 +177,12 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 5 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('Tips'); expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6); @@ -212,17 +190,21 @@ describe('', () => { }); it('should NOT render Tips when tipsShown is 10 or more', async () => { - const mockConfig = makeFakeConfig(); + const uiState = { + bannerData: { + defaultText: '', + warningText: '', + }, + }; persistentStateMock.setData({ tipsShown: 10 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { - config: mockConfig, + uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('Tips'); unmount(); @@ -231,7 +213,6 @@ describe('', () => { it('should show tips until they have been shown 10 times (persistence flow)', async () => { persistentStateMock.setData({ tipsShown: 9 }); - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -242,21 +223,19 @@ describe('', () => { }; // First session - const session1 = renderWithProviders(, { - config: mockConfig, + const session1 = await renderWithProviders(, { uiState, }); - await session1.waitUntilReady(); expect(session1.lastFrame()).toContain('Tips'); expect(persistentStateMock.get('tipsShown')).toBe(10); session1.unmount(); // Second session - state is persisted in the fake - const session2 = renderWithProviders(, { - config: mockConfig, - }); - await session2.waitUntilReady(); + const session2 = await renderWithProviders( + , + {}, + ); expect(session2.lastFrame()).not.toContain('Tips'); session2.unmount(); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index ad5e2f67d2..0b15f917a6 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -1,58 +1,129 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { Box } from 'ink'; -import { Header } from './Header.js'; -import { Tips } from './Tips.js'; +import { Box, Text } from 'ink'; import { UserIdentity } from './UserIdentity.js'; +import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { Banner } from './Banner.js'; import { useBanner } from '../hooks/useBanner.js'; import { useTips } from '../hooks/useTips.js'; +import { theme } from '../semantic-colors.js'; +import { ThemedGradient } from './ThemedGradient.js'; +import { CliSpinner } from './CliSpinner.js'; + +import { isAppleTerminal } from '@google/gemini-cli-core'; interface AppHeaderProps { version: string; showDetails?: boolean; } +const DEFAULT_ICON = `▝▜▄ + ▝▜▄ + ▗▟▀ +▝▀ `; + +/** + * The default Apple Terminal.app adds significant line-height padding between + * rows. This breaks Unicode block-drawing characters that rely on vertical + * adjacency (like half-blocks). This version is perfectly symmetric vertically, + * which makes the padding gaps look like an intentional "scanline" design + * rather than a broken image. + */ +const MAC_TERMINAL_ICON = `▝▜▄ + ▝▜▄ + ▗▟▀ +▗▟▀ `; + export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); - const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState(); + const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState(); const { bannerText } = useBanner(bannerData); const { showTips } = useTips(); + const showHeader = !( + settings.merged.ui.hideBanner || config.getScreenReader() + ); + + const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON; + if (!showDetails) { return ( -
+ {showHeader && ( + + + {ICON} + + + + + Gemini CLI + + v{version} + + + + )} ); } return ( - {!(settings.merged.ui.hideBanner || config.getScreenReader()) && ( - <> -
- {bannerVisible && bannerText && ( - - )} - + {showHeader && ( + + + {ICON} + + + {/* Line 1: Gemini CLI vVersion [Updating] */} + + + Gemini CLI + + v{version} + {updateInfo && ( + + + Updating + + + )} + + + {/* Line 2: Blank */} + + + {/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */} + {settings.merged.ui.showUserIdentity !== false && ( + + )} + + )} - {settings.merged.ui.showUserIdentity !== false && ( - + + {bannerVisible && bannerText && ( + )} + {!(settings.merged.ui.hideTips || config.getScreenReader()) && showTips && } diff --git a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx new file mode 100644 index 0000000000..6b6f0e6210 --- /dev/null +++ b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { AppHeader } from './AppHeader.js'; + +// We mock the entire module to control the isAppleTerminal export +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isAppleTerminal: vi.fn(), + }; +}); + +import { isAppleTerminal } from '@google/gemini-cli-core'; + +describe('AppHeader Icon Rendering', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('renders the default icon in standard terminals', async () => { + vi.mocked(isAppleTerminal).mockReturnValue(false); + + const result = await renderWithProviders(); + await result.waitUntilReady(); + + await expect(result).toMatchSvgSnapshot(); + }); + + it('renders the symmetric icon in Apple Terminal', async () => { + vi.mocked(isAppleTerminal).mockReturnValue(true); + + const result = await renderWithProviders(); + await result.waitUntilReady(); + + await expect(result).toMatchSvgSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index 4386891c7a..1b2decbe16 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -11,56 +11,50 @@ import { ApprovalMode } from '@google/gemini-cli-core'; describe('ApprovalModeIndicator', () => { it('renders correctly for AUTO_EDIT mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for AUTO_EDIT mode with plan enabled', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for PLAN mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for YOLO mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode with plan enabled', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx index b5a981ac7a..7e8f388c82 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -8,22 +8,14 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ApprovalMode } from '@google/gemini-cli-core'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { Command } from '../key/keyBindings.js'; interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; allowPlanMode?: boolean; } -export const APPROVAL_MODE_TEXT = { - AUTO_EDIT: 'auto-accept edits', - PLAN: 'plan', - YOLO: 'YOLO', - HINT_SWITCH_TO_PLAN_MODE: 'shift+tab to plan', - HINT_SWITCH_TO_MANUAL_MODE: 'shift+tab to manual', - HINT_SWITCH_TO_AUTO_EDIT_MODE: 'shift+tab to accept edits', - HINT_SWITCH_TO_YOLO_MODE: 'ctrl+y', -}; - export const ApprovalModeIndicator: React.FC = ({ approvalMode, allowPlanMode, @@ -32,29 +24,32 @@ export const ApprovalModeIndicator: React.FC = ({ let textContent = ''; let subText = ''; + const cycleHint = formatCommand(Command.CYCLE_APPROVAL_MODE); + const yoloHint = formatCommand(Command.TOGGLE_YOLO); + switch (approvalMode) { case ApprovalMode.AUTO_EDIT: textColor = theme.status.warning; - textContent = APPROVAL_MODE_TEXT.AUTO_EDIT; + textContent = 'auto-accept edits'; subText = allowPlanMode - ? APPROVAL_MODE_TEXT.HINT_SWITCH_TO_PLAN_MODE - : APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE; + ? `${cycleHint} to plan` + : `${cycleHint} to manual`; break; case ApprovalMode.PLAN: textColor = theme.status.success; - textContent = APPROVAL_MODE_TEXT.PLAN; - subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE; + textContent = 'plan'; + subText = `${cycleHint} to manual`; break; case ApprovalMode.YOLO: textColor = theme.status.error; - textContent = APPROVAL_MODE_TEXT.YOLO; - subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_YOLO_MODE; + textContent = 'YOLO'; + subText = yoloHint; break; case ApprovalMode.DEFAULT: default: textColor = theme.text.accent; textContent = ''; - subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_AUTO_EDIT_MODE; + subText = `${cycleHint} to accept edits`; break; } diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 1bd29241db..864800a061 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -7,6 +7,8 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { AskUserDialog } from './AskUserDialog.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; @@ -46,7 +48,7 @@ describe('AskUserDialog', () => { ]; it('renders question and options', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -87,6 +88,31 @@ describe('AskUserDialog', () => { writeKey(stdin, '\r'); // Toggle TS writeKey(stdin, '\x1b[B'); // Down writeKey(stdin, '\r'); // Toggle ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\x1b[B'); // Down to Other + writeKey(stdin, '\x1b[B'); // Down to Done + writeKey(stdin, '\r'); // Done + }, + expectedSubmit: { '0': 'TypeScript, ESLint' }, + }, + { + name: 'All of the above', + questions: [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[], + actions: (stdin: { write: (data: string) => void }) => { + writeKey(stdin, '\x1b[B'); // Down to ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\r'); // Toggle All of the above writeKey(stdin, '\x1b[B'); // Down to Other writeKey(stdin, '\x1b[B'); // Down to Done writeKey(stdin, '\r'); // Done @@ -113,7 +139,7 @@ describe('AskUserDialog', () => { ])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => { it(`submits correct values for ${name}`, async () => { const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { }); }); + it('verifies "All of the above" visual state with snapshot', async () => { + const questions = [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + // Navigate to "All of the above" and toggle it + writeKey(stdin, '\x1b[B'); // Down to ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\r'); // Toggle All of the above + + await waitFor(async () => { + await waitUntilReady(); + // Verify visual state (checkmarks on all options) + expect(lastFrame()).toMatchSnapshot(); + }); + }); + it('handles custom option in single select with inline typing', async () => { const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { width={80} availableHeight={10} // Small height to force scrolling />, - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ ui: { useAlternateBuffer } }), + }, ); await waitFor(async () => { @@ -275,7 +340,7 @@ describe('AskUserDialog', () => { ); it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('hides progress header for single question', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('shows keyboard hints', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -395,7 +457,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toContain('Which testing framework?'); writeKey(stdin, '\x1b[C'); // Right arrow @@ -442,7 +503,7 @@ describe('AskUserDialog', () => { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -548,7 +608,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -693,7 +752,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -716,7 +774,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -787,7 +844,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { ]; const onCancel = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { availableTerminalHeight: 5, // Small height to force scroll arrows } as UIState; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { width={80} /> , - { useAlternateBuffer: false }, + { + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }, ); // With height 5 and alternate buffer disabled, it should show scroll arrows (▲) @@ -1257,7 +1317,7 @@ describe('AskUserDialog', () => { availableTerminalHeight: 5, } as UIState; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { width={40} // Small width to force wrapping /> , - { useAlternateBuffer: true }, + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }, ); // Should NOT contain the truncation message @@ -1293,7 +1356,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }); }); }); + + it('expands paste placeholders in multi-select custom option via Done', async () => { + const questions: Question[] = [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [{ label: 'TypeScript', description: '' }], + multiSelect: true, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = await renderWithProviders( + , + { width: 120 }, + ); + + // Select TypeScript + writeKey(stdin, '\r'); + // Down to Other + writeKey(stdin, '\x1b[B'); + + // Simulate bracketed paste of multi-line text into the custom option + const pastedText = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6'; + const ESC = '\x1b'; + writeKey(stdin, `${ESC}[200~${pastedText}${ESC}[201~`); + + // Down to Done and submit + writeKey(stdin, '\x1b[B'); + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + '0': `TypeScript, ${pastedText}`, + }); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 9606513510..b1d23885e6 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -15,15 +15,18 @@ import { } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import type { Question } from '@google/gemini-cli-core'; +import { checkExhaustive, type Question } from '@google/gemini-cli-core'; import { BaseSelectionList } from './shared/BaseSelectionList.js'; import type { SelectionListItem } from '../hooks/useSelectionList.js'; import { TabHeader, type Tab } from './shared/TabHeader.js'; import { useKeypress, type Key } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; -import { checkExhaustive } from '@google/gemini-cli-core'; +import { Command } from '../key/keyMatchers.js'; import { TextInput } from './shared/TextInput.js'; -import { useTextBuffer } from './shared/text-buffer.js'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { + useTextBuffer, + expandPastePlaceholders, +} from './shared/text-buffer.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { DialogFooter } from './shared/DialogFooter.js'; @@ -32,6 +35,7 @@ import { RenderInline } from '../utils/InlineMarkdownRenderer.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { UIStateContext } from '../contexts/UIStateContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; /** Padding for dialog content to prevent text from touching edges. */ const DIALOG_PADDING = 4; @@ -204,6 +208,7 @@ const ReviewView: React.FC = ({ progressHeader, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const unansweredCount = questions.length - Object.keys(answers).length; const hasUnanswered = unansweredCount > 0; @@ -252,7 +257,7 @@ const ReviewView: React.FC = ({ @@ -284,6 +289,7 @@ const TextQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const prefix = '> '; const horizontalPadding = 1; // 1 for cursor @@ -302,10 +308,12 @@ const TextQuestionView: React.FC = ({ const lastTextValueRef = useRef(textValue); useEffect(() => { if (textValue !== lastTextValueRef.current) { - onSelectionChange?.(textValue); + onSelectionChange?.( + expandPastePlaceholders(textValue, buffer.pastedContent), + ); lastTextValueRef.current = textValue; } - }, [textValue, onSelectionChange]); + }, [textValue, onSelectionChange, buffer.pastedContent]); // Handle Ctrl+C to clear all text const handleExtraKeys = useCallback( @@ -319,7 +327,7 @@ const TextQuestionView: React.FC = ({ } return false; }, - [buffer, textValue], + [buffer, textValue, keyMatchers], ); useKeypress(handleExtraKeys, { isActive: true, priority: true }); @@ -387,7 +395,7 @@ interface OptionItem { key: string; label: string; description: string; - type: 'option' | 'other' | 'done'; + type: 'option' | 'other' | 'done' | 'all'; index: number; } @@ -399,6 +407,7 @@ interface ChoiceQuestionState { type ChoiceQuestionAction = | { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } } + | { type: 'TOGGLE_ALL'; payload: { totalOptions: number } } | { type: 'SET_CUSTOM_SELECTED'; payload: { selected: boolean; multiSelect: boolean }; @@ -411,6 +420,25 @@ function choiceQuestionReducer( action: ChoiceQuestionAction, ): ChoiceQuestionState { switch (action.type) { + case 'TOGGLE_ALL': { + const { totalOptions } = action.payload; + const allSelected = state.selectedIndices.size === totalOptions; + if (allSelected) { + return { + ...state, + selectedIndices: new Set(), + }; + } else { + const newIndices = new Set(); + for (let i = 0; i < totalOptions; i++) { + newIndices.add(i); + } + return { + ...state, + selectedIndices: newIndices, + }; + } + } case 'TOGGLE_INDEX': { const { index, multiSelect } = action.payload; const newIndices = new Set(multiSelect ? state.selectedIndices : []); @@ -481,6 +509,7 @@ const ChoiceQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const numOptions = (question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0); @@ -588,11 +617,15 @@ const ChoiceQuestionView: React.FC = ({ } }); if (includeCustomOption && customOption.trim()) { - answers.push(customOption.trim()); + const expanded = expandPastePlaceholders( + customOption, + customBuffer.pastedContent, + ); + answers.push(expanded.trim()); } return answers.join(', '); }, - [questionOptions], + [questionOptions, customBuffer.pastedContent], ); // Synchronize selection changes with parent - only when it actually changes @@ -670,6 +703,7 @@ const ChoiceQuestionView: React.FC = ({ customBuffer, onEditingCustomOption, customOptionText, + keyMatchers, ], ); @@ -689,6 +723,18 @@ const ChoiceQuestionView: React.FC = ({ }, ); + // Add 'All of the above' for multi-select + if (question.multiSelect && questionOptions.length > 1) { + const allItem: OptionItem = { + key: 'all', + label: 'All of the above', + description: 'Select all options', + type: 'all', + index: list.length, + }; + list.push({ key: 'all', value: allItem }); + } + // Only add custom option for choice type, not yesno if (question.type !== 'yesno') { const otherItem: OptionItem = { @@ -741,6 +787,11 @@ const ChoiceQuestionView: React.FC = ({ type: 'TOGGLE_CUSTOM_SELECTED', payload: { multiSelect: true }, }); + } else if (itemValue.type === 'all') { + dispatch({ + type: 'TOGGLE_ALL', + payload: { totalOptions: questionOptions.length }, + }); } else if (itemValue.type === 'done') { // Done just triggers navigation, selections already saved via useEffect onAnswer( @@ -757,16 +808,23 @@ const ChoiceQuestionView: React.FC = ({ } else if (itemValue.type === 'other') { // In single select, selecting other submits it if it has text if (customOptionText.trim()) { - onAnswer(customOptionText.trim()); + onAnswer( + expandPastePlaceholders( + customOptionText, + customBuffer.pastedContent, + ).trim(), + ); } } } }, [ question.multiSelect, + questionOptions.length, selectedIndices, isCustomOptionSelected, customOptionText, + customBuffer.pastedContent, onAnswer, buildAnswerString, ], @@ -786,16 +844,21 @@ const ChoiceQuestionView: React.FC = ({ const TITLE_MARGIN = 1; const FOOTER_HEIGHT = 2; // DialogFooter + margin const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT; + const listHeight = availableHeight ? Math.max(1, availableHeight - overhead) : undefined; - const questionHeight = + + const questionHeightLimit = listHeight && !isAlternateBuffer - ? Math.min(15, Math.max(1, listHeight - DIALOG_PADDING)) + ? question.unconstrainedHeight + ? Math.max(1, listHeight - selectionItems.length * 2) + : Math.min(15, Math.max(1, listHeight - DIALOG_PADDING)) : undefined; + const maxItemsToShow = - listHeight && questionHeight - ? Math.max(1, Math.floor((listHeight - questionHeight) / 2)) + listHeight && questionHeightLimit + ? Math.max(1, Math.floor((listHeight - questionHeightLimit) / 2)) : selectionItems.length; return ( @@ -803,7 +866,7 @@ const ChoiceQuestionView: React.FC = ({ {progressHeader} @@ -832,11 +895,16 @@ const ChoiceQuestionView: React.FC = ({ renderItem={(item, context) => { const optionItem = item.value; const isChecked = - selectedIndices.has(optionItem.index) || - (optionItem.type === 'other' && isCustomOptionSelected); + (optionItem.type === 'option' && + selectedIndices.has(optionItem.index)) || + (optionItem.type === 'other' && isCustomOptionSelected) || + (optionItem.type === 'all' && + selectedIndices.size === questionOptions.length); const showCheck = question.multiSelect && - (optionItem.type === 'option' || optionItem.type === 'other'); + (optionItem.type === 'option' || + optionItem.type === 'other' || + optionItem.type === 'all'); // Render inline text input for custom option if (optionItem.type === 'other') { @@ -934,6 +1002,7 @@ export const AskUserDialog: React.FC = ({ availableHeight: availableHeightProp, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const uiState = useContext(UIStateContext); const availableHeight = availableHeightProp ?? @@ -983,7 +1052,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [onCancel, submitted, isEditingCustomOption], + [onCancel, submitted, isEditingCustomOption, keyMatchers], ); useKeypress(handleCancel, { @@ -1016,7 +1085,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [questions.length, submitted, goToNextTab, goToPrevTab], + [questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers], ); useKeypress(handleNavigation, { @@ -1146,7 +1215,7 @@ export const AskUserDialog: React.FC = ({ navigationActions={ questions.length > 1 ? currentQuestion.type === 'text' || isEditingCustomOption - ? 'Tab/Shift+Tab to switch questions' + ? `${formatCommand(Command.DIALOG_NEXT)}/${formatCommand(Command.DIALOG_PREV)} to switch questions` : '←/→ to switch questions' : currentQuestion.type === 'text' || isEditingCustomOption ? undefined diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index 4d37de24c3..c097028a0d 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -35,6 +35,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ShellExecutionService: { resizePty: vi.fn(), subscribe: vi.fn(() => vi.fn()), + getLogFilePath: vi.fn( + (pid) => `~/.gemini/tmp/background-processes/background-${pid}.log`, + ), + getLogDir: vi.fn(() => '~/.gemini/tmp/background-processes'), }, }; }); @@ -141,7 +145,7 @@ describe('', () => { it('renders the output of the active shell', async () => { const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -162,7 +165,7 @@ describe('', () => { it('renders tabs for multiple shells', async () => { const width = 100; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -183,7 +185,7 @@ describe('', () => { it('highlights the focused state', async () => { const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -204,7 +205,7 @@ describe('', () => { it('resizes the PTY on mount and when dimensions change', async () => { const width = 80; - const { rerender, waitUntilReady, unmount } = render( + const { rerender, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 76, - 21, + 20, ); rerender( @@ -237,19 +237,18 @@ describe('', () => { /> , ); - await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 96, - 27, + 26, ); unmount(); }); it('renders the process list when isListOpenProp is true', async () => { const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -270,7 +268,7 @@ describe('', () => { it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => { const width = 80; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( ', () => { , width, ); - await waitUntilReady(); // Simulate down arrow to select the second process (handled by RadioButtonSelect) await act(async () => { simulateKey({ name: 'down' }); }); - await waitUntilReady(); // Simulate Ctrl+L (handled by BackgroundShellDisplay) await act(async () => { simulateKey({ name: 'l', ctrl: true }); }); - await waitUntilReady(); expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); @@ -304,7 +299,7 @@ describe('', () => { it('kills the highlighted process when Ctrl+K is pressed in list view', async () => { const width = 80; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( ', () => { , width, ); - await waitUntilReady(); // Initial state: shell1 (active) is highlighted @@ -325,13 +319,11 @@ describe('', () => { await act(async () => { simulateKey({ name: 'down' }); }); - await waitUntilReady(); // Press Ctrl+K await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); - await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); unmount(); @@ -339,7 +331,7 @@ describe('', () => { it('kills the active process when Ctrl+K is pressed in output view', async () => { const width = 80; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( ', () => { , width, ); - await waitUntilReady(); await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); - await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); unmount(); @@ -366,7 +356,7 @@ describe('', () => { it('scrolls to active shell when list opens', async () => { // shell2 is active const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -398,7 +387,7 @@ describe('', () => { mockShells.set(exitedShell.pid, exitedShell); const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index 03cd10823d..bb4c1f26da 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -10,15 +10,17 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { theme } from '../semantic-colors.js'; import { ShellExecutionService, + shortenPath, + tildeifyPath, type AnsiOutput, type AnsiLine, type AnsiToken, } from '@google/gemini-cli-core'; import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; -import { Command, keyMatchers } from '../keyMatchers.js'; +import { Command } from '../key/keyMatchers.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { formatCommand } from '../utils/keybindingUtils.js'; +import { formatCommand } from '../key/keybindingUtils.js'; import { ScrollableList, type ScrollableListRef, @@ -30,6 +32,7 @@ import { RadioButtonSelect, type RadioSelectItem, } from './shared/RadioButtonSelect.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; interface BackgroundShellDisplayProps { shells: Map; @@ -42,8 +45,14 @@ interface BackgroundShellDisplayProps { const CONTENT_PADDING_X = 1; const BORDER_WIDTH = 2; // Left and Right border -const HEADER_HEIGHT = 3; // 2 for border, 1 for header +const MAIN_BORDER_HEIGHT = 2; // Top and Bottom border +const HEADER_HEIGHT = 1; +const FOOTER_HEIGHT = 1; +const TOTAL_OVERHEAD_HEIGHT = + MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT; +const PROCESS_LIST_HEADER_HEIGHT = 3; // 1 padding top, 1 text, 1 margin bottom const TAB_DISPLAY_HORIZONTAL_PADDING = 4; +const LOG_PATH_OVERHEAD = 7; // "Log: " (5) + paddingX (2) const formatShellCommandForDisplay = (command: string, maxWidth: number) => { const commandFirstLine = command.split('\n')[0]; @@ -60,6 +69,7 @@ export const BackgroundShellDisplay = ({ isFocused, isListOpenProp, }: BackgroundShellDisplayProps) => { + const keyMatchers = useKeyMatchers(); const { dismissBackgroundShell, setActiveBackgroundShellPid, @@ -79,7 +89,7 @@ export const BackgroundShellDisplay = ({ if (!activePid) return; const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2); - const ptyHeight = Math.max(1, height - HEADER_HEIGHT); + const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT); ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight); }, [activePid, width, height]); @@ -148,7 +158,7 @@ export const BackgroundShellDisplay = ({ if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { if (highlightedPid) { - dismissBackgroundShell(highlightedPid); + void dismissBackgroundShell(highlightedPid); // If we killed the active one, the list might update via props } return true; @@ -169,7 +179,7 @@ export const BackgroundShellDisplay = ({ } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { - dismissBackgroundShell(activeShell.pid); + void dismissBackgroundShell(activeShell.pid); return true; } @@ -334,7 +344,10 @@ export const BackgroundShellDisplay = ({ }} onHighlight={(pid) => setHighlightedPid(pid)} isFocused={isFocused} - maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header + maxItemsToShow={Math.max( + 1, + height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT, + )} renderItem={( item, { isSelected: _isSelected, titleColor: _titleColor }, @@ -381,6 +394,23 @@ export const BackgroundShellDisplay = ({ ); }; + const renderFooter = () => { + const pidToDisplay = isListOpenProp + ? (highlightedPid ?? activePid) + : activePid; + if (!pidToDisplay) return null; + const logPath = ShellExecutionService.getLogFilePath(pidToDisplay); + const displayPath = shortenPath( + tildeifyPath(logPath), + width - LOG_PATH_OVERHEAD, + ); + return ( + + Log: {displayPath} + + ); + }; + const renderOutput = () => { const lines = typeof output === 'string' ? output.split('\n') : output; @@ -427,7 +457,7 @@ export const BackgroundShellDisplay = ({ height="100%" width="100%" borderStyle="single" - borderColor={isFocused ? theme.border.focused : undefined} + borderColor={isFocused ? theme.ui.focus : undefined} > {renderTabs()} @@ -452,6 +482,7 @@ export const BackgroundShellDisplay = ({ {isListOpenProp ? renderProcessList() : renderOutput()} + {renderFooter()} ); }; diff --git a/packages/cli/src/ui/components/Banner.test.tsx b/packages/cli/src/ui/components/Banner.test.tsx index 46c47b8a71..7219cf4861 100644 --- a/packages/cli/src/ui/components/Banner.test.tsx +++ b/packages/cli/src/ui/components/Banner.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { Banner } from './Banner.js'; import { describe, it, expect } from 'vitest'; @@ -12,22 +12,23 @@ describe('Banner', () => { it.each([ ['warning mode', true, 'Warning Message'], ['info mode', false, 'Info Message'], + ['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'], ])('renders in %s', async (_, isWarning, text) => { - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = await renderWithProviders( , ); - await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('handles newlines in text', async () => { const text = 'Line 1\\nLine 2'; - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = await renderWithProviders( , ); - await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); }); diff --git a/packages/cli/src/ui/components/Banner.tsx b/packages/cli/src/ui/components/Banner.tsx index 99f573a68e..3f9777aa45 100644 --- a/packages/cli/src/ui/components/Banner.tsx +++ b/packages/cli/src/ui/components/Banner.tsx @@ -14,20 +14,21 @@ export function getFormattedBannerContent( isWarning: boolean, subsequentLineColor: string, ): ReactNode { - if (isWarning) { - return ( - {rawText.replace(/\\n/g, '\n')} - ); - } - const text = rawText.replace(/\\n/g, '\n'); const lines = text.split('\n'); return lines.map((line, index) => { if (index === 0) { + if (isWarning) { + return ( + + {line} + + ); + } return ( - {line} + {line} ); } diff --git a/packages/cli/src/ui/components/BubblingRegression.test.tsx b/packages/cli/src/ui/components/BubblingRegression.test.tsx index b91943b019..5e83a6b9eb 100644 --- a/packages/cli/src/ui/components/BubblingRegression.test.tsx +++ b/packages/cli/src/ui/components/BubblingRegression.test.tsx @@ -30,7 +30,7 @@ describe('Key Bubbling Regression', () => { ]; it('does not navigate when pressing "j" or "k" in a focused text input', async () => { - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame } = await renderWithProviders( ', () => { ]; it('renders nothing when list is empty', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); @@ -30,15 +29,14 @@ describe('', () => { { status: 'completed', label: 'Task 1' }, { status: 'cancelled', label: 'Task 2' }, ]; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); it('renders summary view correctly (collapsed)', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( ', () => { toggleHint="toggle me" />, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders expanded view correctly', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( ', () => { toggleHint="toggle me" />, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -68,10 +64,9 @@ describe('', () => { { status: 'completed', label: 'Task 1' }, { status: 'pending', label: 'Task 2' }, ]; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/Checklist.tsx b/packages/cli/src/ui/components/Checklist.tsx index cfbd4268fd..d9fb51278c 100644 --- a/packages/cli/src/ui/components/Checklist.tsx +++ b/packages/cli/src/ui/components/Checklist.tsx @@ -5,9 +5,9 @@ */ import type React from 'react'; +import { useMemo } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import { useMemo } from 'react'; import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js'; export interface ChecklistProps { diff --git a/packages/cli/src/ui/components/ChecklistItem.test.tsx b/packages/cli/src/ui/components/ChecklistItem.test.tsx index 0f6c0eb0b0..c71af523e1 100644 --- a/packages/cli/src/ui/components/ChecklistItem.test.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx @@ -15,9 +15,9 @@ describe('', () => { { status: 'in_progress', label: 'Doing this' }, { status: 'completed', label: 'Done this' }, { status: 'cancelled', label: 'Skipped this' }, + { status: 'blocked', label: 'Blocked this' }, ] as ChecklistItemData[])('renders %s item correctly', async (item) => { - const { lastFrame, waitUntilReady } = render(); - await waitUntilReady(); + const { lastFrame } = await render(); expect(lastFrame()).toMatchSnapshot(); }); @@ -27,12 +27,11 @@ describe('', () => { label: 'This is a very long text that should be truncated because the wrap prop is set to truncate', }; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -42,12 +41,11 @@ describe('', () => { label: 'This is a very long text that should wrap because the default behavior is wrapping', }; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/ChecklistItem.tsx b/packages/cli/src/ui/components/ChecklistItem.tsx index 6e08e0af6b..065c79d516 100644 --- a/packages/cli/src/ui/components/ChecklistItem.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.tsx @@ -13,7 +13,8 @@ export type ChecklistStatus = | 'pending' | 'in_progress' | 'completed' - | 'cancelled'; + | 'cancelled' + | 'blocked'; export interface ChecklistItemData { status: ChecklistStatus; @@ -48,6 +49,12 @@ const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({ ✗ ); + case 'blocked': + return ( + + ⛔ + + ); default: checkExhaustive(status); } @@ -70,6 +77,7 @@ export const ChecklistItem: React.FC = ({ return theme.text.accent; case 'completed': case 'cancelled': + case 'blocked': return theme.text.secondary; case 'pending': return theme.text.primary; diff --git a/packages/cli/src/ui/components/CliSpinner.test.tsx b/packages/cli/src/ui/components/CliSpinner.test.tsx index 738c487698..4da6abb199 100644 --- a/packages/cli/src/ui/components/CliSpinner.test.tsx +++ b/packages/cli/src/ui/components/CliSpinner.test.tsx @@ -17,8 +17,7 @@ describe('', () => { it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => { expect(debugState.debugNumAnimatedComponents).toBe(0); - const { waitUntilReady, unmount } = renderWithProviders(); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); expect(debugState.debugNumAnimatedComponents).toBe(1); unmount(); expect(debugState.debugNumAnimatedComponents).toBe(0); @@ -26,11 +25,9 @@ describe('', () => { it('should not render when showSpinner is false', async () => { const settings = createMockSettings({ ui: { showSpinner: false } }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( - , - { settings }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + settings, + }); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); diff --git a/packages/cli/src/ui/components/ColorsDisplay.test.tsx b/packages/cli/src/ui/components/ColorsDisplay.test.tsx new file mode 100644 index 0000000000..d934831c0e --- /dev/null +++ b/packages/cli/src/ui/components/ColorsDisplay.test.tsx @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { ColorsDisplay } from './ColorsDisplay.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { themeManager } from '../themes/theme-manager.js'; +import type { Theme, ColorsTheme } from '../themes/theme.js'; +import type { SemanticColors } from '../themes/semantic-tokens.js'; + +describe('ColorsDisplay', () => { + beforeEach(() => { + vi.spyOn(themeManager, 'getSemanticColors').mockReturnValue({ + text: { + primary: '#ffffff', + secondary: '#cccccc', + link: '#0000ff', + accent: '#ff00ff', + response: '#ffffff', + }, + background: { + primary: '#000000', + message: '#111111', + input: '#222222', + focus: '#333333', + diff: { + added: '#003300', + removed: '#330000', + }, + }, + border: { + default: '#555555', + }, + ui: { + comment: '#666666', + symbol: '#cccccc', + active: '#0000ff', + dark: '#333333', + focus: '#0000ff', + gradient: undefined, + }, + status: { + error: '#ff0000', + success: '#00ff00', + warning: '#ffff00', + }, + }); + + vi.spyOn(themeManager, 'getActiveTheme').mockReturnValue({ + name: 'Test Theme', + type: 'dark', + colors: {} as unknown as ColorsTheme, + semanticColors: { + text: { + primary: '#ffffff', + secondary: '#cccccc', + link: '#0000ff', + accent: '#ff00ff', + response: '#ffffff', + }, + background: { + primary: '#000000', + message: '#111111', + input: '#222222', + diff: { + added: '#003300', + removed: '#330000', + }, + }, + border: { + default: '#555555', + }, + ui: { + comment: '#666666', + symbol: '#cccccc', + active: '#0000ff', + dark: '#333333', + focus: '#0000ff', + gradient: undefined, + }, + status: { + error: '#ff0000', + success: '#00ff00', + warning: '#ffff00', + }, + } as unknown as SemanticColors, + } as unknown as Theme); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders correctly', async () => { + const mockTheme = themeManager.getActiveTheme(); + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const output = lastFrame(); + + // Check for title and description + expect(output).toContain('How do colors get applied?'); + expect(output).toContain('Hex:'); + + // Check for some color names and values expect(output).toContain('text.primary'); + expect(output).toContain('#ffffff'); + expect(output).toContain('background.diff.added'); + expect(output).toContain('#003300'); + expect(output).toContain('border.default'); + expect(output).toContain('#555555'); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/ColorsDisplay.tsx b/packages/cli/src/ui/components/ColorsDisplay.tsx new file mode 100644 index 0000000000..96b98bf540 --- /dev/null +++ b/packages/cli/src/ui/components/ColorsDisplay.tsx @@ -0,0 +1,277 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import Gradient from 'ink-gradient'; +import { theme } from '../semantic-colors.js'; +import type { Theme } from '../themes/theme.js'; + +interface StandardColorRow { + type: 'standard'; + name: string; + value: string; +} + +interface GradientColorRow { + type: 'gradient'; + name: string; + value: string[]; +} + +interface BackgroundColorRow { + type: 'background'; + name: string; + value: string; +} + +type ColorRow = StandardColorRow | GradientColorRow | BackgroundColorRow; + +const VALUE_COLUMN_WIDTH = 10; + +const COLOR_DESCRIPTIONS: Record = { + 'text.primary': 'Primary text color (uses terminal default if blank)', + 'text.secondary': 'Secondary/dimmed text color', + 'text.link': 'Hyperlink and highlighting color', + 'text.accent': 'Accent color for emphasis', + 'text.response': + 'Color for model response text (uses terminal default if blank)', + 'background.primary': 'Main terminal background color', + 'background.message': 'Subtle background for message blocks', + 'background.input': 'Background for the input prompt', + 'background.focus': 'Background highlight for selected/focused items', + 'background.diff.added': 'Background for added lines in diffs', + 'background.diff.removed': 'Background for removed lines in diffs', + 'border.default': 'Standard border color', + 'ui.comment': 'Color for code comments and metadata', + 'ui.symbol': 'Color for technical symbols and UI icons', + 'ui.active': 'Border color for active or running elements', + 'ui.dark': 'Deeply dimmed color for subtle UI elements', + 'ui.focus': + 'Color for focused elements (e.g. selected menu items, focused borders)', + 'status.error': 'Color for error messages and critical status', + 'status.success': 'Color for success messages and positive status', + 'status.warning': 'Color for warnings and cautionary status', +}; + +interface ColorsDisplayProps { + activeTheme: Theme; +} + +/** + * Determines a contrasting text color (black or white) based on the background color's luminance. + */ +function getContrastingTextColor(hex: string): string { + if (!hex || !hex.startsWith('#') || hex.length < 7) { + // Fallback for invalid hex codes or named colors + return theme.text.primary; + } + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + // Using YIQ formula to determine luminance + const yiq = (r * 299 + g * 587 + b * 114) / 1000; + return yiq >= 128 ? '#000000' : '#FFFFFF'; +} + +export const ColorsDisplay: React.FC = ({ + activeTheme, +}) => { + const semanticColors = activeTheme.semanticColors; + + const backgroundRows: BackgroundColorRow[] = []; + const standardRows: StandardColorRow[] = []; + let gradientRow: GradientColorRow | null = null; + + if (semanticColors.ui.gradient && semanticColors.ui.gradient.length > 0) { + gradientRow = { + type: 'gradient', + name: 'ui.gradient', + value: semanticColors.ui.gradient, + }; + } + + /** + * Recursively flattens the semanticColors object. + */ + const flattenColors = (obj: object, path: string = '') => { + for (const [key, value] of Object.entries(obj)) { + if (value === undefined || value === null) continue; + const newPath = path ? `${path}.${key}` : key; + + if (key === 'gradient' && Array.isArray(value)) { + // Gradient handled separately + continue; + } + + if (typeof value === 'object' && !Array.isArray(value)) { + flattenColors(value, newPath); + } else if (typeof value === 'string') { + if (newPath.startsWith('background.')) { + backgroundRows.push({ + type: 'background', + name: newPath, + value, + }); + } else { + standardRows.push({ + type: 'standard', + name: newPath, + value, + }); + } + } + } + }; + + flattenColors(semanticColors); + + // Final order: Backgrounds first, then Standards, then Gradient + const allRows: ColorRow[] = [ + ...backgroundRows, + ...standardRows, + ...(gradientRow ? [gradientRow] : []), + ]; + + return ( + + + + DEVELOPER TOOLS (Not visible to users) + + + + How do colors get applied? + + + + • Hex: Rendered exactly by modern terminals. Not + overridden by app themes. + + + • Blank: Uses your terminal's default + foreground/background. + + + • Compatibility: On older terminals, hex is + approximated to the nearest ANSI color. + + + • ANSI Names: 'red', + 'green', etc. are mapped to your terminal app's + palette. + + + + + + {/* Header */} + + + + Value + + + + + Name + + + + + {/* All Rows */} + + {allRows.map((row) => { + if (row.type === 'standard') return renderStandardRow(row); + if (row.type === 'gradient') return renderGradientRow(row); + if (row.type === 'background') return renderBackgroundRow(row); + return null; + })} + + + ); +}; + +function renderStandardRow({ name, value }: StandardColorRow) { + const isHex = value.startsWith('#'); + const displayColor = isHex ? value : theme.text.primary; + const description = COLOR_DESCRIPTIONS[name] || ''; + + return ( + + + {value || '(blank)'} + + + + {name} + + + {description} + + + + ); +} + +function renderGradientRow({ name, value }: GradientColorRow) { + const description = COLOR_DESCRIPTIONS[name] || ''; + + return ( + + + {value.map((c, i) => ( + + {c} + + ))} + + + + + {name} + + + + {description} + + + + ); +} + +function renderBackgroundRow({ name, value }: BackgroundColorRow) { + const description = COLOR_DESCRIPTIONS[name] || ''; + + return ( + + + + {value || 'default'} + + + + + {name} + + + {description} + + + + ); +} diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 12deda3e76..8df5f690e7 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -6,8 +6,8 @@ import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import { render } from '../../test-utils/render.js'; +import { act, useEffect } from 'react'; import { Box, Text } from 'ink'; -import { useEffect } from 'react'; import { Composer } from './Composer.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { @@ -34,6 +34,7 @@ import { StreamingState } from '../types.js'; import { TransientMessageType } from '../../utils/events.js'; import type { LoadedSettings } from '../../config/settings.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; +import type { TextBuffer } from './shared/text-buffer.js'; const composerTestControls = vi.hoisted(() => ({ suggestionsVisible: false, @@ -182,7 +183,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => ideContextState: null, geminiMdFileCount: 0, renderMarkdown: true, - filteredConsoleMessages: [], history: [], sessionStats: { sessionId: 'test-session', @@ -230,7 +230,7 @@ const createMockConfig = (overrides = {}): Config => getDebugMode: vi.fn(() => false), getAccessibility: vi.fn(() => ({})), getMcpServers: vi.fn(() => ({})), - isPlanEnabled: vi.fn(() => false), + isPlanEnabled: vi.fn(() => true), getToolRegistry: () => ({ getTool: vi.fn(), }), @@ -251,7 +251,7 @@ const renderComposer = async ( config = createMockConfig(), uiActions = createMockUIActions(), ) => { - const result = render( + const result = await render( @@ -262,17 +262,26 @@ const renderComposer = async ( , ); - await result.waitUntilReady(); + + // Wait for shortcuts hint debounce if using fake timers + if (vi.isFakeTimers()) { + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + } + return result; }; describe('Composer', () => { beforeEach(() => { + vi.useFakeTimers(); composerTestControls.suggestionsVisible = false; composerTestControls.isAlternateBuffer = false; }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -363,7 +372,7 @@ describe('Composer', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { - subject: 'Detailed in-history thought', + subject: 'Thinking about code', description: 'Full text is already in history', }, }); @@ -374,7 +383,7 @@ describe('Composer', () => { const { lastFrame } = await renderComposer(uiState, settings); const output = lastFrame(); - expect(output).toContain('LoadingIndicator: Thinking ...'); + expect(output).toContain('LoadingIndicator: Thinking...'); }); it('hides shortcuts hint while loading', async () => { @@ -391,20 +400,20 @@ describe('Composer', () => { expect(output).not.toContain('ShortcutsHint'); }); - it('renders LoadingIndicator without thought when loadingPhrases is off', async () => { + it('renders LoadingIndicator with thought when loadingPhrases is off', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Hidden', description: 'Should not show' }, }); const settings = createMockSettings({ - merged: { ui: { loadingPhrases: 'off' } }, + ui: { loadingPhrases: 'off' }, }); const { lastFrame } = await renderComposer(uiState, settings); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); - expect(output).not.toContain('Should not show'); + expect(output).toContain('LoadingIndicator: Hidden'); }); it('does not render LoadingIndicator when waiting for confirmation', async () => { @@ -746,13 +755,6 @@ describe('Composer', () => { it('shows DetailedMessagesDisplay when showErrorDetails is true', async () => { const uiState = createMockUIState({ showErrorDetails: true, - filteredConsoleMessages: [ - { - type: 'error', - content: 'Test error', - count: 1, - }, - ], }); const { lastFrame } = await renderComposer(uiState); @@ -809,6 +811,28 @@ describe('Composer', () => { }); describe('Shortcuts Hint', () => { + it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => { + const { lastFrame } = await renderComposer( + createMockUIState({ + buffer: { text: '' } as unknown as TextBuffer, + cleanUiDetailsVisible: false, + }), + ); + + expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint'); + }); + + it('hides shortcuts hint when text is typed in buffer', async () => { + const uiState = createMockUIState({ + buffer: { text: 'hello' } as unknown as TextBuffer, + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + it('hides shortcuts hint when showShortcutsHint setting is false', async () => { const uiState = createMockUIState(); const settings = createMockSettings({ @@ -857,6 +881,17 @@ describe('Composer', () => { expect(lastFrame()).toContain('ShortcutsHint'); }); + it('hides shortcuts hint while loading when full UI details are visible', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: true, + streamingState: StreamingState.Responding, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + it('hides shortcuts hint while loading in minimal mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, @@ -930,9 +965,10 @@ describe('Composer', () => { streamingState: StreamingState.Idle, }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHelp'); + unmount(); }); it('hides shortcuts help while streaming', async () => { @@ -941,9 +977,10 @@ describe('Composer', () => { streamingState: StreamingState.Responding, }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); + unmount(); }); it('hides shortcuts help when action is required', async () => { @@ -956,9 +993,10 @@ describe('Composer', () => { ), }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 2adc370ed5..89c9c9d3d6 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -151,11 +151,30 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : undefined, ); const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions; + const isModelIdle = uiState.streamingState === StreamingState.Idle; + const isBufferEmpty = uiState.buffer.text.length === 0; + const canShowShortcutsHint = + isModelIdle && isBufferEmpty && !hasPendingActionRequired; + const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] = + useState(canShowShortcutsHint); + + useEffect(() => { + if (!canShowShortcutsHint) { + setShowShortcutsHintDebounced(false); + return; + } + + const timeout = setTimeout(() => { + setShowShortcutsHintDebounced(true); + }, 200); + + return () => clearTimeout(timeout); + }, [canShowShortcutsHint]); + + const shouldReserveSpaceForShortcutsHint = + settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions; const showShortcutsHint = - settings.merged.ui.showShortcutsHint && - !hideShortcutsHintForSuggestions && - !hideMinimalModeHintWhileBusy && - !hasPendingActionRequired; + shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced; const showMinimalModeBleedThrough = !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; @@ -168,7 +187,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { !showUiDetails && (showMinimalInlineLoading || showMinimalBleedThroughRow || - showShortcutsHint); + shouldReserveSpaceForShortcutsHint); return ( { inline thought={ uiState.streamingState === - StreamingState.WaitingForConfirmation || - settings.merged.ui.loadingPhrases === 'off' + StreamingState.WaitingForConfirmation ? undefined : uiState.thought } @@ -221,7 +239,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : uiState.currentLoadingPhrase } thoughtLabel={ - inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + inlineThinkingMode === 'full' ? 'Thinking...' : undefined } elapsedTime={uiState.elapsedTime} /> @@ -231,6 +249,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { marginTop={isNarrow ? 1 : 0} flexDirection="column" alignItems={isNarrow ? 'flex-start' : 'flex-end'} + minHeight={ + showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0 + } > {showUiDetails && showShortcutsHint && } @@ -254,8 +275,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { inline thought={ uiState.streamingState === - StreamingState.WaitingForConfirmation || - settings.merged.ui.loadingPhrases === 'off' + StreamingState.WaitingForConfirmation ? undefined : uiState.thought } @@ -265,7 +285,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : uiState.currentLoadingPhrase } thoughtLabel={ - inlineThinkingMode === 'full' ? 'Thinking ...' : undefined + inlineThinkingMode === 'full' ? 'Thinking...' : undefined } elapsedTime={uiState.elapsedTime} /> @@ -287,11 +307,13 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} - {(showMinimalContextBleedThrough || showShortcutsHint) && ( + {(showMinimalContextBleedThrough || + shouldReserveSpaceForShortcutsHint) && ( {showMinimalContextBleedThrough && ( { terminalWidth={uiState.terminalWidth} /> )} - {showShortcutsHint && ( - - - - )} + + {showShortcutsHint && } + )} @@ -373,7 +391,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { marginTop={ (showApprovalIndicator || uiState.shellModeActive) && - isNarrow + !isNarrow ? 1 : 0 } @@ -404,7 +422,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { = ({ useKeypress( (key: Key) => { if (state.type === 'ASK_CONFIRMATION') { - if (key.name === 'y' || key.name === 'return') { + if (key.name === 'y' || key.name === 'enter') { state.resolve(true); return true; } @@ -220,7 +220,7 @@ export const ConfigExtensionDialog: React.FC = ({ } } if (state.type === 'DONE' || state.type === 'ERROR') { - if (key.name === 'return' || key.name === 'escape') { + if (key.name === 'enter' || key.name === 'escape') { onClose(); return true; } diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx index 36ecbcbe5f..b4ae8b93b1 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx @@ -43,10 +43,7 @@ describe('ConfigInitDisplay', () => { }); it('renders initial state', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( - , - ); - await waitUntilReady(); + const { lastFrame } = await renderWithProviders(); expect(lastFrame()).toMatchSnapshot(); }); @@ -59,7 +56,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = await renderWithProviders(); // Wait for listener to be registered await waitFor(() => { @@ -97,7 +94,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = await renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); @@ -133,7 +130,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = await renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); diff --git a/packages/cli/src/ui/components/ConsentPrompt.test.tsx b/packages/cli/src/ui/components/ConsentPrompt.test.tsx index dd69c44dd5..09a2dde16e 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.test.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.test.tsx @@ -33,14 +33,13 @@ describe('ConsentPrompt', () => { it('renders a string prompt with MarkdownDisplay', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); expect(MockedMarkdownDisplay).toHaveBeenCalledWith( { @@ -55,14 +54,13 @@ describe('ConsentPrompt', () => { it('renders a ReactNode prompt directly', async () => { const prompt = Are you sure?; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(MockedMarkdownDisplay).not.toHaveBeenCalled(); expect(lastFrame()).toContain('Are you sure?'); @@ -71,14 +69,13 @@ describe('ConsentPrompt', () => { it('calls onConfirm with true when "Yes" is selected', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { @@ -92,14 +89,13 @@ describe('ConsentPrompt', () => { it('calls onConfirm with false when "No" is selected', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { @@ -113,14 +109,13 @@ describe('ConsentPrompt', () => { it('passes correct items to RadioButtonSelect', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); expect(MockedRadioButtonSelect).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx index cb8db1a895..b7662c3a26 100644 --- a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx @@ -10,10 +10,9 @@ import { describe, it, expect } from 'vitest'; describe('ConsoleSummaryDisplay', () => { it('renders nothing when errorCount is 0', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -22,10 +21,9 @@ describe('ConsoleSummaryDisplay', () => { [1, '1 error'], [5, '5 errors'], ])('renders correct message for %i errors', async (count, expectedText) => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain(expectedText); expect(output).toContain('✖'); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index f48cfb2a31..1049e97912 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -26,8 +26,7 @@ const renderWithWidth = async ( props: React.ComponentProps, ) => { useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); - const result = render(); - await result.waitUntilReady(); + const result = await render(); return result; }; diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index ae272d6145..d8ec1650ee 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { describe, it, expect, vi } from 'vitest'; @@ -17,56 +17,71 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -vi.mock('../../config/settings.js', () => ({ - DEFAULT_MODEL_CONFIGS: {}, - LoadedSettings: class { - constructor() { - // this.merged = {}; - } - }, -})); - describe('ContextUsageDisplay', () => { - it('renders correct percentage left', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders correct percentage used', async () => { + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('50% context left'); + expect(output).toContain('50% used'); unmount(); }); - it('renders short label when terminal width is small', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders correctly when usage is 0%', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('0% used'); + unmount(); + }); + + it('renders abbreviated label when terminal width is small', async () => { + const { lastFrame, unmount } = await renderWithProviders( , + { width: 80 }, ); - await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('80%'); - expect(output).not.toContain('context left'); + expect(output).toContain('20%'); + expect(output).not.toContain('context used'); unmount(); }); - it('renders 0% when full', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders 80% correctly', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('80% used'); + unmount(); + }); + + it('renders 100% when full', async () => { + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('0% context left'); + expect(output).toContain('100% used'); unmount(); }); }); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index 1c1d24cc2d..3e82145dca 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -7,6 +7,11 @@ import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { getContextUsagePercentage } from '../utils/contextUsage.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { + MIN_TERMINAL_WIDTH_FOR_FULL_LABEL, + DEFAULT_COMPRESSION_THRESHOLD, +} from '../constants.js'; export const ContextUsageDisplay = ({ promptTokenCount, @@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({ terminalWidth, }: { promptTokenCount: number; - model: string; + model: string | undefined; terminalWidth: number; }) => { + const settings = useSettings(); const percentage = getContextUsagePercentage(promptTokenCount, model); - const percentageLeft = ((1 - percentage) * 100).toFixed(0); + const percentageUsed = (percentage * 100).toFixed(0); - const label = terminalWidth < 100 ? '%' : '% context left'; + const threshold = + settings.merged.model?.compressionThreshold ?? + DEFAULT_COMPRESSION_THRESHOLD; + + let textColor = theme.text.secondary; + if (percentage >= 1.0) { + textColor = theme.status.error; + } else if (percentage >= threshold) { + textColor = theme.status.warning; + } + + const label = + terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% used'; return ( - - {percentageLeft} + + {percentageUsed} {label} ); diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx index de7cb3a888..cc20a142dd 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.test.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx @@ -22,8 +22,7 @@ describe('CopyModeWarning', () => { mockUseUIState.mockReturnValue({ copyModeEnabled: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -32,10 +31,10 @@ describe('CopyModeWarning', () => { mockUseUIState.mockReturnValue({ copyModeEnabled: true, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame()).toContain('In Copy Mode'); - expect(lastFrame()).toContain('Press any key to exit'); + expect(lastFrame()).toContain('Use Page Up/Down to scroll'); + expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit'); unmount(); }); }); diff --git a/packages/cli/src/ui/components/CopyModeWarning.tsx b/packages/cli/src/ui/components/CopyModeWarning.tsx index 8d5423bb89..4b6328274b 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.tsx @@ -19,7 +19,8 @@ export const CopyModeWarning: React.FC = () => { return ( - In Copy Mode. Press any key to exit. + In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other key + to exit. ); diff --git a/packages/cli/src/ui/components/DebugProfiler.test.tsx b/packages/cli/src/ui/components/DebugProfiler.test.tsx index d4c0e28902..a014c740f0 100644 --- a/packages/cli/src/ui/components/DebugProfiler.test.tsx +++ b/packages/cli/src/ui/components/DebugProfiler.test.tsx @@ -242,8 +242,7 @@ describe('DebugProfiler Component', () => { showDebugProfiler: false, constrainHeight: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -257,8 +256,7 @@ describe('DebugProfiler Component', () => { profiler.totalIdleFrames = 5; profiler.totalFlickerFrames = 2; - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); const output = lastFrame(); expect(output).toContain('Renders: 10 (total)'); @@ -275,8 +273,7 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { waitUntilReady, unmount } = await render(); await act(async () => { coreEvents.emitModelChanged('new-model'); @@ -295,8 +292,7 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { waitUntilReady, unmount } = await render(); await act(async () => { appEvents.emit(AppEvent.SelectionWarning); diff --git a/packages/cli/src/ui/components/DebugProfiler.tsx b/packages/cli/src/ui/components/DebugProfiler.tsx index e68b3018dd..b162373473 100644 --- a/packages/cli/src/ui/components/DebugProfiler.tsx +++ b/packages/cli/src/ui/components/DebugProfiler.tsx @@ -171,6 +171,16 @@ export const DebugProfiler = () => { appEvents.on(eventName, handler); } + // Register handlers for extension lifecycle events emitted on coreEvents + // but not part of the CoreEvent enum, to prevent false-positive idle warnings. + const extensionEvents = [ + 'extensionsStarting', + 'extensionsStopping', + ] as const; + for (const eventName of extensionEvents) { + coreEvents.on(eventName, handler); + } + return () => { stdin.off('data', handler); stdout.off('resize', handler); @@ -183,6 +193,10 @@ export const DebugProfiler = () => { appEvents.off(eventName, handler); } + for (const eventName of extensionEvents) { + coreEvents.off(eventName, handler); + } + profiler.profilersActive--; }; }, []); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index 108db073d5..30f98a6eda 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -4,12 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { ConsoleMessageItem } from '../types.js'; import { Box } from 'ink'; import type React from 'react'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { useConsoleMessages } from '../hooks/useConsoleMessages.js'; + +vi.mock('../hooks/useConsoleMessages.js', () => ({ + useConsoleMessages: vi.fn(), +})); vi.mock('./shared/ScrollableList.js', () => ({ ScrollableList: ({ @@ -28,16 +34,19 @@ vi.mock('./shared/ScrollableList.js', () => ({ })); describe('DetailedMessagesDisplay', () => { + beforeEach(() => { + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: [], + clearConsoleMessages: vi.fn(), + }); + }); it('renders nothing when messages are empty', async () => { - const { lastFrame, waitUntilReady, unmount } = render( - , + const { lastFrame, unmount } = await renderWithProviders( + , + { + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), + }, ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -49,36 +58,76 @@ describe('DetailedMessagesDisplay', () => { { type: 'error', content: 'Error message', count: 1 }, { type: 'debug', content: 'Debug message', count: 1 }, ]; + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: messages, + clearConsoleMessages: vi.fn(), + }); - const { lastFrame, waitUntilReady, unmount } = render( - , + const { lastFrame, unmount } = await renderWithProviders( + , + { + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), + }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); unmount(); }); + it('shows the F12 hint even in low error verbosity mode', async () => { + const messages: ConsoleMessageItem[] = [ + { type: 'error', content: 'Error message', count: 1 }, + ]; + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: messages, + clearConsoleMessages: vi.fn(), + }); + + const { lastFrame, unmount } = await renderWithProviders( + , + { + settings: createMockSettings({ ui: { errorVerbosity: 'low' } }), + }, + ); + expect(lastFrame()).toContain('(F12 to close)'); + unmount(); + }); + + it('shows the F12 hint in full error verbosity mode', async () => { + const messages: ConsoleMessageItem[] = [ + { type: 'error', content: 'Error message', count: 1 }, + ]; + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: messages, + clearConsoleMessages: vi.fn(), + }); + + const { lastFrame, unmount } = await renderWithProviders( + , + { + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), + }, + ); + expect(lastFrame()).toContain('(F12 to close)'); + unmount(); + }); + it('renders message counts', async () => { const messages: ConsoleMessageItem[] = [ { type: 'log', content: 'Repeated message', count: 5 }, ]; + vi.mocked(useConsoleMessages).mockReturnValue({ + consoleMessages: messages, + clearConsoleMessages: vi.fn(), + }); - const { lastFrame, waitUntilReady, unmount } = render( - , + const { lastFrame, unmount } = await renderWithProviders( + , + { + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), + }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index ff88afa888..2daa1c39e3 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useRef, useCallback } from 'react'; import type React from 'react'; +import { useRef, useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { ConsoleMessageItem } from '../types.js'; @@ -13,9 +13,10 @@ import { ScrollableList, type ScrollableListRef, } from './shared/ScrollableList.js'; +import { useConsoleMessages } from '../hooks/useConsoleMessages.js'; +import { useConfig } from '../contexts/ConfigContext.js'; interface DetailedMessagesDisplayProps { - messages: ConsoleMessageItem[]; maxHeight: number | undefined; width: number; hasFocus: boolean; @@ -25,9 +26,19 @@ const iconBoxWidth = 3; export const DetailedMessagesDisplay: React.FC< DetailedMessagesDisplayProps -> = ({ messages, maxHeight, width, hasFocus }) => { +> = ({ maxHeight, width, hasFocus }) => { const scrollableListRef = useRef>(null); + const { consoleMessages } = useConsoleMessages(); + const config = useConfig(); + + const messages = useMemo(() => { + if (config.getDebugMode()) { + return consoleMessages; + } + return consoleMessages.filter((msg) => msg.type !== 'debug'); + }, [consoleMessages, config]); + const borderAndPadding = 3; const estimatedItemHeight = useCallback( diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index c938cddd73..1ce932a26c 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -86,6 +86,8 @@ describe('DialogManager', () => { stats: undefined, proQuotaRequest: null, validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, }, shouldShowIdePrompt: false, isFolderTrustDialogOpen: false, @@ -111,11 +113,10 @@ describe('DialogManager', () => { }; it('renders nothing by default', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: baseUiState as Partial as UIState }, ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -141,6 +142,8 @@ describe('DialogManager', () => { resolve: vi.fn(), }, validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, }, }, 'ProQuotaDialog', @@ -212,7 +215,7 @@ describe('DialogManager', () => { it.each(testCases)( 'renders %s when state is %o', async (uiStateOverride, expectedComponent) => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -221,14 +224,13 @@ describe('DialogManager', () => { } as Partial as UIState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain(expectedComponent); unmount(); }, ); it('prioritizes folder trust ahead of resume context confirmation', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -249,7 +251,7 @@ describe('DialogManager', () => { }); it('renders custom dialogs only after higher-priority startup dialogs', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 41fe3aaea4..89f33effe5 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -13,13 +13,15 @@ import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; import { AuthInProgress } from '../auth/AuthInProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; +import { BannedAccountDialog } from '../auth/BannedAccountDialog.js'; import { ApiAuthDialog } from '../auth/ApiAuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; import { ValidationDialog } from './ValidationDialog.js'; -import { runExitCleanup } from '../../utils/cleanup.js'; -import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; +import { OverageMenuDialog } from './OverageMenuDialog.js'; +import { EmptyWalletDialog } from './EmptyWalletDialog.js'; +import { relaunchApp } from '../../utils/processUtils.js'; import { SessionBrowser } from './SessionBrowser.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; @@ -34,9 +36,6 @@ import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { NewAgentsNotification } from './NewAgentsNotification.js'; import { AgentConfigDialog } from './AgentConfigDialog.js'; -import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; -import { useCallback } from 'react'; -import { SettingScope } from '../../config/settings.js'; import { PolicyUpdateDialog } from './PolicyUpdateDialog.js'; import { ResumeContextSwitchDialog } from './ResumeContextSwitchDialog.js'; @@ -60,56 +59,8 @@ export const DialogManager = ({ terminalHeight, staticExtraHeight, terminalWidth: uiTerminalWidth, - shouldShowRetentionWarning, - sessionsToDeleteCount, } = uiState; - const handleKeep120Days = useCallback(() => { - settings.setValue( - SettingScope.User, - 'general.sessionRetention.warningAcknowledged', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.enabled', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.maxAge', - '120d', - ); - }, [settings]); - - const handleKeep30Days = useCallback(() => { - settings.setValue( - SettingScope.User, - 'general.sessionRetention.warningAcknowledged', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.enabled', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.maxAge', - '30d', - ); - }, [settings]); - - if (shouldShowRetentionWarning && sessionsToDeleteCount !== undefined) { - return ( - - ); - } - if (uiState.adminSettingsChanged) { return ; } @@ -128,6 +79,8 @@ export const DialogManager = ({ isModelNotFoundError={ !!uiState.quota.proQuotaRequest.isModelNotFoundError } + authType={uiState.quota.proQuotaRequest.authType} + tierName={config?.getUserTierName()} onChoice={uiActions.handleProQuotaChoice} /> ); @@ -144,6 +97,28 @@ export const DialogManager = ({ /> ); } + if (uiState.quota.overageMenuRequest) { + return ( + + ); + } + if (uiState.quota.emptyWalletRequest) { + return ( + + ); + } if (uiState.isPolicyUpdateDialogOpen) { return ( uiActions.closeSettingsDialog()} - onRestartRequest={async () => { - await runExitCleanup(); - process.exit(RELAUNCH_EXIT_CODE); - }} + onRestartRequest={relaunchApp} availableTerminalHeight={terminalHeight - staticExtraHeight} - config={config} /> ); @@ -309,6 +279,7 @@ export const DialogManager = ({ displayName={uiState.selectedAgentDisplayName} definition={uiState.selectedAgentDefinition} settings={settings} + availableTerminalHeight={terminalHeight - staticExtraHeight} onClose={uiActions.closeAgentConfigDialog} onSave={async () => { // Reload agent registry to pick up changes @@ -321,6 +292,21 @@ export const DialogManager = ({ ); } + if (uiState.accountSuspensionInfo) { + return ( + + { + process.exit(1); + }} + onChangeAuth={() => { + uiActions.clearAccountSuspension(); + }} + /> + + ); + } if (uiState.isAuthenticating) { return ( ); } + if (uiState.isAuthDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx index 36832c1662..18b47def7b 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -4,12 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SettingScope } from '../../config/settings.js'; -import type { LoadedSettings } from '../../config/settings.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; import { act } from 'react'; import { waitFor } from '../../test-utils/async.js'; import { debugLogger } from '@google/gemini-cli-core'; @@ -53,44 +51,41 @@ describe('EditorSettingsDialog', () => { vi.clearAllMocks(); }); - const renderWithProvider = (ui: React.ReactNode) => - render({ui}); + const renderWithProvider = async (ui: React.ReactElement) => + renderWithProviders(ui); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = renderWithProvider( + const { lastFrame } = await renderWithProvider( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('calls onSelect when an editor is selected', async () => { const onSelect = vi.fn(); - const { lastFrame, waitUntilReady } = renderWithProvider( + const { lastFrame } = await renderWithProvider( , ); - await waitUntilReady(); expect(lastFrame()).toContain('VS Code'); }); it('switches focus between editor and scope sections on Tab', async () => { - const { lastFrame, stdin, waitUntilReady } = renderWithProvider( + const { lastFrame, stdin, waitUntilReady } = await renderWithProvider( , ); - await waitUntilReady(); // Initial focus on editor expect(lastFrame()).toContain('> Select Editor'); @@ -129,14 +124,13 @@ describe('EditorSettingsDialog', () => { it('calls onExit when Escape is pressed', async () => { const onExit = vi.fn(); - const { stdin, waitUntilReady } = renderWithProvider( + const { stdin, waitUntilReady } = await renderWithProvider( , ); - await waitUntilReady(); await act(async () => { stdin.write('\u001B'); // Escape @@ -164,14 +158,13 @@ describe('EditorSettingsDialog', () => { }, } as unknown as LoadedSettings; - const { lastFrame, waitUntilReady } = renderWithProvider( + const { lastFrame } = await renderWithProvider( , ); - await waitUntilReady(); const frame = lastFrame() || ''; if (!frame.includes('(Also modified')) { diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index f75b1c27b8..7fa0d2a2cf 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -13,18 +13,18 @@ import { type EditorDisplay, } from '../editors/editorSettingsManager.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; -import type { - LoadableSettingScope, - LoadedSettings, +import { + SettingScope, + type LoadableSettingScope, + type LoadedSettings, } from '../../config/settings.js'; -import { SettingScope } from '../../config/settings.js'; import { type EditorType, isEditorAvailable, EDITOR_DISPLAY_NAMES, + coreEvents, } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; -import { coreEvents } from '@google/gemini-cli-core'; interface EditorDialogProps { onSelect: ( diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx new file mode 100644 index 0000000000..74de1a8a41 --- /dev/null +++ b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { act } from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EmptyWalletDialog } from './EmptyWalletDialog.js'; + +const writeKey = (stdin: { write: (data: string) => void }, key: string) => { + act(() => { + stdin.write(key); + }); +}; + +describe('EmptyWalletDialog', () => { + const mockOnChoice = vi.fn(); + const mockOnGetCredits = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('rendering', () => { + it('should match snapshot with fallback available', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should match snapshot without fallback', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should display the model name and usage limit message', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + + const output = lastFrame() ?? ''; + expect(output).toContain('gemini-2.5-pro'); + expect(output).toContain('Usage limit reached'); + unmount(); + }); + + it('should display purchase prompt and credits update notice', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + + const output = lastFrame() ?? ''; + expect(output).toContain('purchase more AI Credits'); + expect(output).toContain( + 'Newly purchased AI credits may take a few minutes to update', + ); + unmount(); + }); + + it('should display reset time when provided', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + + const output = lastFrame() ?? ''; + expect(output).toContain('3:45 PM'); + expect(output).toContain('Access resets at'); + unmount(); + }); + + it('should not display reset time when not provided', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('Access resets at'); + unmount(); + }); + + it('should display slash command hints', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + + const output = lastFrame() ?? ''; + expect(output).toContain('/stats'); + expect(output).toContain('/model'); + expect(output).toContain('/auth'); + unmount(); + }); + }); + + describe('onChoice handling', () => { + it('should call onGetCredits and onChoice when get_credits is selected', async () => { + // get_credits is the first item, so just press Enter + const { unmount, stdin } = await renderWithProviders( + , + ); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnGetCredits).toHaveBeenCalled(); + expect(mockOnChoice).toHaveBeenCalledWith('get_credits'); + }); + unmount(); + }); + + it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => { + const { unmount, stdin } = await renderWithProviders( + , + ); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('get_credits'); + }); + unmount(); + }); + + it('should call onChoice with use_fallback when selected', async () => { + // With fallback: items are [get_credits, use_fallback, stop] + // use_fallback is the second item: Down + Enter + const { unmount, stdin } = await renderWithProviders( + , + ); + + writeKey(stdin, '\x1b[B'); // Down arrow + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('use_fallback'); + }); + unmount(); + }); + + it('should call onChoice with stop when selected', async () => { + // Without fallback: items are [get_credits, stop] + // stop is the second item: Down + Enter + const { unmount, stdin } = await renderWithProviders( + , + ); + + writeKey(stdin, '\x1b[B'); // Down arrow + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('stop'); + }); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.tsx new file mode 100644 index 0000000000..25d85829d3 --- /dev/null +++ b/packages/cli/src/ui/components/EmptyWalletDialog.tsx @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { theme } from '../semantic-colors.js'; + +/** Available choices in the empty wallet dialog */ +export type EmptyWalletChoice = 'get_credits' | 'use_fallback' | 'stop'; + +interface EmptyWalletDialogProps { + /** The model that hit the quota limit */ + failedModel: string; + /** The fallback model to offer (omit if none available) */ + fallbackModel?: string; + /** Time when access resets (human-readable) */ + resetTime?: string; + /** Callback to log click and open the browser for purchasing credits */ + onGetCredits?: () => void; + /** Callback when user makes a selection */ + onChoice: (choice: EmptyWalletChoice) => void; +} + +export function EmptyWalletDialog({ + failedModel, + fallbackModel, + resetTime, + onGetCredits, + onChoice, +}: EmptyWalletDialogProps): React.JSX.Element { + const items: Array<{ + label: string; + value: EmptyWalletChoice; + key: string; + }> = [ + { + label: 'Get AI Credits - Open browser to purchase credits', + value: 'get_credits', + key: 'get_credits', + }, + ]; + + if (fallbackModel) { + items.push({ + label: `Switch to ${fallbackModel}`, + value: 'use_fallback', + key: 'use_fallback', + }); + } + + items.push({ + label: 'Stop - Abort request', + value: 'stop', + key: 'stop', + }); + + const handleSelect = (choice: EmptyWalletChoice) => { + if (choice === 'get_credits') { + onGetCredits?.(); + } + onChoice(choice); + }; + + return ( + + + + Usage limit reached for {failedModel}. + + {resetTime && Access resets at {resetTime}.} + + + /stats + {' '} + model for usage details + + + + /model + {' '} + to switch models. + + + + /auth + {' '} + to switch to API key. + + + + To continue using this model now, purchase more AI Credits. + + + + Newly purchased AI credits may take a few minutes to update. + + + + How would you like to proceed? + + + + + + ); +} diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index c9def1a8c2..d6fc23dd70 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -7,11 +7,11 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; import { waitFor } from '../../test-utils/async.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; -import { openFileInEditor } from '../utils/editorUtils.js'; +import { Command } from '../key/keyMatchers.js'; import { ApprovalMode, validatePlanContent, @@ -19,6 +19,7 @@ import { type FileSystemService, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; vi.mock('../utils/editorUtils.js', () => ({ openFileInEditor: vi.fn(), @@ -41,10 +42,6 @@ vi.mock('node:fs', async (importOriginal) => { ...actual, existsSync: vi.fn(), realpathSync: vi.fn((p) => p), - promises: { - ...actual.promises, - readFile: vi.fn(), - }, }; }); @@ -142,8 +139,9 @@ Implement a comprehensive authentication system with multiple providers. vi.restoreAllMocks(); }); - const renderDialog = (options?: { useAlternateBuffer?: boolean }) => - renderWithProviders( + const renderDialog = async (options?: { useAlternateBuffer?: boolean }) => { + const useAlternateBuffer = options?.useAlternateBuffer ?? true; + return renderWithProviders( useAlternateBuffer, } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ ui: { useAlternateBuffer } }), }, ); + }; describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])( 'useAlternateBuffer: $useAlternateBuffer', ({ useAlternateBuffer }) => { it('renders correctly with plan content', async () => { - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); // Advance timers to pass the debounce period await act(async () => { @@ -198,7 +201,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onApprove with AUTO_EDIT when first option is selected', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -216,7 +221,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onApprove with DEFAULT when second option is selected', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -235,7 +242,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onFeedback when feedback is typed and submitted', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -266,7 +275,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onCancel when Esc is pressed', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -292,7 +303,9 @@ Implement a comprehensive authentication system with multiple providers. error: 'File not found', }); - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -308,7 +321,9 @@ Implement a comprehensive authentication system with multiple providers. it('displays error state when plan file is empty', async () => { vi.mocked(validatePlanContent).mockResolvedValue('Plan file is empty.'); - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -327,7 +342,9 @@ Implement a comprehensive authentication system with multiple providers. returnDisplay: 'Read file', }); - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -343,7 +360,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('allows number key quick selection', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -362,7 +381,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('clears feedback text when Ctrl+C is pressed while editing', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -406,6 +427,7 @@ Implement a comprehensive authentication system with multiple providers. }: { children: React.ReactNode; }) => { + const keyMatchers = useKeyMatchers(); useKeypress( (key) => { if (keyMatchers[Command.QUIT](key)) { @@ -418,33 +440,38 @@ Implement a comprehensive authentication system with multiple providers. return <>{children}; }; - const { stdin, lastFrame } = renderWithProviders( - - - , - { - useAlternateBuffer, - config: { - getTargetDir: () => mockTargetDir, - getIdeMode: () => false, - isTrustedFolder: () => true, - storage: { - getPlansDir: () => mockPlansDir, - }, - getFileSystemService: (): FileSystemService => ({ - readTextFile: vi.fn(), - writeTextFile: vi.fn(), + const { stdin, lastFrame } = await act(async () => + renderWithProviders( + + + , + { + config: { + getTargetDir: () => mockTargetDir, + getIdeMode: () => false, + isTrustedFolder: () => true, + storage: { + getPlansDir: () => mockPlansDir, + }, + getFileSystemService: (): FileSystemService => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + }), + getUseAlternateBuffer: () => useAlternateBuffer ?? true, + } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ + ui: { useAlternateBuffer: useAlternateBuffer ?? true }, }), - } as unknown as import('@google/gemini-cli-core').Config, - }, + }, + ), ); await act(async () => { @@ -486,7 +513,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('does not submit empty feedback when Enter is pressed', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -513,7 +542,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('allows arrow navigation while typing feedback to change selection', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -544,8 +575,10 @@ Implement a comprehensive authentication system with multiple providers. expect(onFeedback).not.toHaveBeenCalled(); }); - it('opens plan in external editor when Ctrl+X is pressed', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => { + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -555,27 +588,16 @@ Implement a comprehensive authentication system with multiple providers. expect(lastFrame()).toContain('Add user authentication'); }); - // Reset the mock to track the second call during refresh - vi.mocked(processSingleFileContent).mockClear(); - // Press Ctrl+X await act(async () => { writeKey(stdin, '\x18'); // Ctrl+X }); await waitFor(() => { - expect(openFileInEditor).toHaveBeenCalledWith( - mockPlanFullPath, - expect.anything(), - expect.anything(), - undefined, + expect(onFeedback).toHaveBeenCalledWith( + 'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.', ); }); - - // Verify that content is refreshed (processSingleFileContent called again) - await waitFor(() => { - expect(processSingleFileContent).toHaveBeenCalled(); - }); }); }, ); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 6a5da1c299..4124a7c6d7 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -22,8 +22,9 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { AskUserDialog } from './AskUserDialog.js'; import { openFileInEditor } from '../utils/editorUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; -import { formatCommand } from '../utils/keybindingUtils.js'; +import { Command } from '../key/keyMatchers.js'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; export interface ExitPlanModeDialogProps { planPath: string; @@ -147,6 +148,7 @@ export const ExitPlanModeDialog: React.FC = ({ width, availableHeight, }) => { + const keyMatchers = useKeyMatchers(); const config = useConfig(); const { stdin, setRawMode } = useStdin(); const planState = usePlanContent(planPath, config); @@ -156,11 +158,15 @@ export const ExitPlanModeDialog: React.FC = ({ const handleOpenEditor = useCallback(async () => { try { await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor()); + + onFeedback( + 'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.', + ); refresh(); } catch (err) { debugLogger.error('Failed to open plan in editor:', err); } - }, [planPath, stdin, setRawMode, getPreferredEditor, refresh]); + }, [planPath, stdin, setRawMode, getPreferredEditor, refresh, onFeedback]); useKeypress( (key) => { @@ -243,6 +249,7 @@ export const ExitPlanModeDialog: React.FC = ({ ], placeholder: 'Type your feedback...', multiSelect: false, + unconstrainedHeight: false, }, ]} onSubmit={(answers) => { diff --git a/packages/cli/src/ui/components/ExitWarning.test.tsx b/packages/cli/src/ui/components/ExitWarning.test.tsx index 6d495a5e21..a504670d03 100644 --- a/packages/cli/src/ui/components/ExitWarning.test.tsx +++ b/packages/cli/src/ui/components/ExitWarning.test.tsx @@ -24,8 +24,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: false, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -36,8 +35,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: true, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame()).toContain('Press Ctrl+C again to exit'); unmount(); }); @@ -48,8 +46,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: false, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame()).toContain('Press Ctrl+D again to exit'); unmount(); }); @@ -60,8 +57,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: true, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index bbda51d8f0..de6e8096ec 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -5,11 +5,12 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig, ExitCodes } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { act } from 'react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; -import { ExitCodes } from '@google/gemini-cli-core'; import * as processUtils from '../../utils/processUtils.js'; vi.mock('../../utils/processUtils.js', () => ({ @@ -47,10 +48,9 @@ describe('FolderTrustDialog', () => { }); it('should render the dialog with title and description', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Do you trust the files in this folder?'); expect(lastFrame()).toContain( @@ -66,23 +66,24 @@ describe('FolderTrustDialog', () => { mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`), hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`), skills: Array.from({ length: 10 }, (_, i) => `skill${i}`), + agents: [], settings: Array.from({ length: 10 }, (_, i) => `setting${i}`), discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 24 }, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('hidden'); unmount(); @@ -95,23 +96,24 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 14 }, }, ); - await waitUntilReady(); // With maxHeight=4, the intro text (4 lines) will take most of the space. // The discovery results will likely be hidden. expect(lastFrame()).toContain('hidden'); @@ -125,23 +127,24 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 10 }, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('hidden'); unmount(); }); @@ -152,19 +155,21 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), // Initially constrained uiState: { constrainHeight: true, terminalHeight: 24 }, }, @@ -173,9 +178,7 @@ describe('FolderTrustDialog', () => { // Initial state: truncated await waitFor(() => { expect(lastFrame()).toContain('Do you trust the files in this folder?'); - // In standard terminal mode, the expansion hint is handled globally by ToastDisplay - // via AppContainer, so it should not be present in the dialog's local frame. - expect(lastFrame()).not.toContain('Press Ctrl+O'); + expect(lastFrame()).toContain('Press Ctrl+O'); expect(lastFrame()).toContain('hidden'); }); @@ -183,14 +186,15 @@ describe('FolderTrustDialog', () => { // because it's handled in AppContainer. // But we can re-render with constrainHeight: false. const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } = - renderWithProviders( + await renderWithProviders( , { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: false, terminalHeight: 24 }, }, ); @@ -207,10 +211,10 @@ describe('FolderTrustDialog', () => { it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => { const onSelect = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( - , - ); - await waitUntilReady(); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderWithProviders( + , + ); await act(async () => { stdin.write('\u001b[27u'); // Press kitty escape key @@ -235,10 +239,9 @@ describe('FolderTrustDialog', () => { }); it('should display restart message when isRestarting is true', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Gemini CLI is restarting'); unmount(); @@ -246,11 +249,12 @@ describe('FolderTrustDialog', () => { it('should call relaunchApp when isRestarting is true', async () => { vi.useFakeTimers(); - const relaunchApp = vi.spyOn(processUtils, 'relaunchApp'); - const { waitUntilReady, unmount } = renderWithProviders( + const relaunchApp = vi + .spyOn(processUtils, 'relaunchApp') + .mockResolvedValue(undefined); + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); await vi.advanceTimersByTimeAsync(250); expect(relaunchApp).toHaveBeenCalled(); unmount(); @@ -259,11 +263,12 @@ describe('FolderTrustDialog', () => { it('should not call relaunchApp if unmounted before timeout', async () => { vi.useFakeTimers(); - const relaunchApp = vi.spyOn(processUtils, 'relaunchApp'); - const { waitUntilReady, unmount } = renderWithProviders( + const relaunchApp = vi + .spyOn(processUtils, 'relaunchApp') + .mockResolvedValue(undefined); + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); // Unmount immediately (before 250ms) unmount(); @@ -274,10 +279,9 @@ describe('FolderTrustDialog', () => { }); it('should not call process.exit when "r" is pressed and isRestarting is false', async () => { - const { stdin, waitUntilReady, unmount } = renderWithProviders( + const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); - await waitUntilReady(); await act(async () => { stdin.write('r'); @@ -293,30 +297,27 @@ describe('FolderTrustDialog', () => { describe('directory display', () => { it('should correctly display the folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Trust folder (project)'); unmount(); }); it('should correctly display the parent folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Trust parent folder (user)'); unmount(); }); it('should correctly display an empty parent folder name for a directory directly under root', async () => { mockedCwd.mockReturnValue('/project'); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Trust parent folder ()'); unmount(); }); @@ -328,11 +329,12 @@ describe('FolderTrustDialog', () => { mcps: ['mcp1'], hooks: ['hook1'], skills: ['skill1'], + agents: ['agent1'], settings: ['general', 'ui'], discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { { width: 80 }, ); - await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('• Commands (2):'); expect(lastFrame()).toContain('- cmd1'); @@ -351,6 +352,8 @@ describe('FolderTrustDialog', () => { expect(lastFrame()).toContain('- hook1'); expect(lastFrame()).toContain('• Skills (1):'); expect(lastFrame()).toContain('- skill1'); + expect(lastFrame()).toContain('• Agents (1):'); + expect(lastFrame()).toContain('- agent1'); expect(lastFrame()).toContain('• Setting overrides (2):'); expect(lastFrame()).toContain('- general'); expect(lastFrame()).toContain('- ui'); @@ -363,18 +366,18 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: ['Dangerous setting detected!'], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Security Warnings:'); expect(lastFrame()).toContain('Dangerous setting detected!'); unmount(); @@ -386,18 +389,18 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: ['Failed to load custom commands'], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Discovery Errors:'); expect(lastFrame()).toContain('Failed to load custom commands'); unmount(); @@ -409,23 +412,24 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { width: 80, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), uiState: { constrainHeight: false, terminalHeight: 15 }, }, ); - await waitUntilReady(); // In alternate buffer + expanded, the title should be visible (StickyHeader) expect(lastFrame()).toContain('Do you trust the files in this folder?'); // And it should NOT use MaxSizedBox truncation @@ -442,12 +446,13 @@ describe('FolderTrustDialog', () => { mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`], hooks: [`${ansiRed}hook-with-ansi${ansiReset}`], skills: [`${ansiRed}skill-with-ansi${ansiReset}`], + agents: [], settings: [`${ansiRed}setting-with-ansi${ansiReset}`], discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`], securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { { width: 100, uiState: { terminalHeight: 40 } }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('cmd-with-ansi'); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 2067a5dc3a..5f226b7d15 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -9,8 +9,10 @@ import type React from 'react'; import { useEffect, useState, useCallback } from 'react'; import { theme } from '../semantic-colors.js'; import stripAnsi from 'strip-ansi'; -import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; -import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { + RadioButtonSelect, + type RadioSelectItem, +} from './shared/RadioButtonSelect.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { Scrollable } from './shared/Scrollable.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -54,9 +56,7 @@ export const FolderTrustDialog: React.FC = ({ useEffect(() => { let timer: ReturnType; if (isRestarting) { - timer = setTimeout(async () => { - await relaunchApp(); - }, 250); + timer = setTimeout(relaunchApp, 250); } return () => { if (timer) clearTimeout(timer); @@ -135,6 +135,7 @@ export const FolderTrustDialog: React.FC = ({ { label: 'MCP Servers', items: discoveryResults?.mcps ?? [] }, { label: 'Hooks', items: discoveryResults?.hooks ?? [] }, { label: 'Skills', items: discoveryResults?.skills ?? [] }, + { label: 'Agents', items: discoveryResults?.agents ?? [] }, { label: 'Setting overrides', items: discoveryResults?.settings ?? [] }, ].filter((g) => g.items.length > 0); @@ -313,9 +314,5 @@ export const FolderTrustDialog: React.FC = ({ ); - return isAlternateBuffer ? ( - {content} - ) : ( - content - ); + return {content}; }; diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 143e8319a3..c0a52af868 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -4,12 +4,35 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; -import { createMockSettings } from '../../test-utils/settings.js'; import { Footer } from './Footer.js'; -import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core'; -import type { SessionStatsState } from '../contexts/SessionContext.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { type Config } from '@google/gemini-cli-core'; +import path from 'node:path'; + +// Normalize paths to POSIX slashes for stable cross-platform snapshots. +const normalizeFrame = (frame: string | undefined) => { + if (!frame) return frame; + return frame.replace(/\\/g, '/'); +}; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + isDevelopment: false, + }, +})); + +vi.mock('../../utils/installationInfo.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + get isDevelopment() { + return mocks.isDevelopment; + }, + }; +}); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = @@ -27,19 +50,46 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { const defaultProps = { model: 'gemini-pro', - targetDir: - '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long', + targetDir: path.join( + path.parse(process.cwd()).root, + 'Users', + 'test', + 'project', + 'foo', + 'bar', + 'and', + 'some', + 'more', + 'directories', + 'to', + 'make', + 'it', + 'long', + ), branchName: 'main', }; -const mockSessionStats: SessionStatsState = { - sessionId: 'test-session', +const mockConfig = { + getTargetDir: () => defaultProps.targetDir, + getDebugMode: () => false, + getModel: () => defaultProps.model, + getIdeMode: () => false, + isTrustedFolder: () => true, + getExtensionRegistryURI: () => undefined, +} as unknown as Config; + +const mockSessionStats = { + sessionId: 'test-session-id', sessionStartTime: new Date(), - lastPromptTokenCount: 0, promptCount: 0, + lastPromptTokenCount: 150000, metrics: { - models: {}, + files: { + totalLinesAdded: 12, + totalLinesRemoved: 4, + }, tools: { + count: 0, totalCalls: 0, totalSuccess: 0, totalFail: 0, @@ -48,245 +98,275 @@ const mockSessionStats: SessionStatsState = { accept: 0, reject: 0, modify: 0, - [ToolCallDecision.AUTO_ACCEPT]: 0, + auto_accept: 0, }, byName: {}, + latency: { avg: 0, max: 0, min: 0 }, }, - files: { - totalLinesAdded: 0, - totalLinesRemoved: 0, + models: { + 'gemini-pro': { + api: { + totalRequests: 0, + totalErrors: 0, + totalLatencyMs: 0, + }, + tokens: { + input: 0, + prompt: 0, + candidates: 0, + total: 1500, + cached: 0, + thoughts: 0, + tool: 0, + }, + roles: {}, + }, }, }, }; describe('