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/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index 70a413f13a..54c404c7c1 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -193,7 +193,7 @@ runs: 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/'" + if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/' && inputs.npm-tag != 'latest'" working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | 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/.gitignore b/.gitignore index a2a6553cd3..ebb94151e8 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ gemini-debug.log .gemini-clipboard/ .eslintcache evals/logs/ + +temp_agents/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f77d0f9152..c71fbe2e22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,20 +60,45 @@ 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. + + ```bash + ./scripts/review.sh [model] + ``` + + **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 diff --git a/README.md b/README.md index 959b5a9534..93485498ed 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 @@ -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 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/preview.md b/docs/changelogs/preview.md index cc5c559365..a830cc12c6 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.33.0-preview.4 +# Preview release: v0.33.0-preview.14 -Released: March 06, 2026 +Released: March 10, 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). @@ -29,6 +29,13 @@ npm install -g @google/gemini-cli@preview ## What's Changed +- fix(patch): cherry-pick 1b69637 to release/v0.33.0-preview.13-pr-21467 + [CONFLICTS] by @gemini-cli-robot in + [#21930](https://github.com/google-gemini/gemini-cli/pull/21930) +- fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to + patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 by + @gemini-cli-robot in + [#21922](https://github.com/google-gemini/gemini-cli/pull/21922) - fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch version v0.33.0-preview.3 and create version 0.33.0-preview.4 by @gemini-cli-robot in @@ -198,4 +205,4 @@ npm install -g @google/gemini-cli@preview [#20991](https://github.com/google-gemini/gemini-cli/pull/20991) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.4 +https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.14 diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index 6cafb7dd52..167801ca05 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -8,7 +8,8 @@ and parameters. | Command | Description | Example | | ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ | | `gemini` | Start interactive REPL | `gemini` | -| `gemini "query"` | Query non-interactively, then exit | `gemini "explain this project"` | +| `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 most recent session | `gemini -r "latest"` | @@ -20,9 +21,9 @@ 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 @@ -47,7 +48,7 @@ These commands are available within the interactive REPL. | `--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 | | `--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` | 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/plan-mode.md b/docs/cli/plan-mode.md index 617f8492fb..c7a2f4bd4e 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -150,6 +150,27 @@ Plan Mode's default tool restrictions are managed by the 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 By default, read-only MCP tools require user confirmation in Plan Mode. You can diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 5565a5e1f6..337fa30cb9 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -22,18 +22,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 | `true` | -| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | +| 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 @@ -66,7 +67,7 @@ they appear in the UI. | 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` | @@ -125,6 +126,7 @@ they appear in the UI. | ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | | Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `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` | diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index c812d37965..f57badb689 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 | | -------------- | -------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- | @@ -88,173 +46,145 @@ Environment variables can be used to override the settings in the file. | `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` | -**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: +1. Set your Google Cloud project ID: + - To send telemetry to a separate project: - **macOS/Linux** + **macOS/Linux** - ```bash - export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" - ``` + ```bash + export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" + ``` - **Windows (PowerShell)** + **Windows (PowerShell)** - ```powershell - $env:OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" - ``` + ```powershell + $env:OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" + ``` - - For telemetry in the same project as inference: + - To send telemetry to the same project as inference: - **macOS/Linux** + **macOS/Linux** - ```bash - export GOOGLE_CLOUD_PROJECT="your-project-id" - ``` + ```bash + export GOOGLE_CLOUD_PROJECT="your-project-id" + ``` - **Windows (PowerShell)** + **Windows (PowerShell)** - ```powershell - $env:GOOGLE_CLOUD_PROJECT="your-project-id" - ``` + ```powershell + $env:GOOGLE_CLOUD_PROJECT="your-project-id" + ``` -2. Authenticate with Google Cloud: - - If using a user account: - ```bash - gcloud auth application-default login - ``` - - If using a service account: +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: - **macOS/Linux** + **macOS/Linux** - ```bash - export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" - ``` + ```bash + export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" + ``` - **Windows (PowerShell)** + **Windows (PowerShell)** - ```powershell - $env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\service-account.json" - ``` + ```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`: -3. Make sure your account or service account has these IAM roles: - - Cloud Trace Agent - - Monitoring Metric Writer - - Logs Writer + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp", + "useCliAuth": true + } + } + ``` -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" - ``` + > **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. -### Authenticating with CLI Credentials +3. Ensure your account or service account has these IAM roles: + - Cloud Trace Agent + - Monitoring Metric Writer + - Logs Writer -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. +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" + ``` -To enable this, set the `useCliAuth` property in your `telemetry` settings to -`true`: +### Direct export -```json -{ - "telemetry": { - "enabled": true, - "target": "gcp", - "useCliAuth": true - } -} -``` +We recommend using direct export to send telemetry directly to Google Cloud +services. -**Important:** +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. -- 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. +### View Google Cloud telemetry -### Direct export (recommended) +After you enable telemetry and run Gemini CLI, you can view your data in the +Google Cloud Console. -Sends telemetry directly to Google Cloud services. No collector needed. +- **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) -1. Enable telemetry in your `.gemini/settings.json`: - ```json - { - "telemetry": { - "enabled": true, - "target": "gcp" - } - } - ``` -2. Run Gemini CLI and send prompts. -3. View logs, metrics, and traces: - - Open the Google Cloud Console in your browser after sending prompts: - - 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: -### Collector-based export (advanced) +- [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) -For custom processing, filtering, or routing, use an OpenTelemetry collector to -forward data to Google Cloud. - -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, metrics, and traces: - - Open the Google Cloud Console in your browser after sending prompts: - - 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 - - Open `~/.gemini/tmp//otel/collector-gcp.log` to view local - collector logs. - -### Monitoring Dashboards +#### 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) @@ -262,661 +192,998 @@ 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`). - -### Collector-based export (advanced) - -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. +For advanced local telemetry setups (such as Jaeger or Genkit), see the +[Local development guide](../local-development.md#viewing-traces). ## Logs, metrics, and traces -The following section describes the structure of logs, metrics, and traces -generated for Gemini CLI. +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) +- `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) + +
### 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. ##### 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` -### Traces +Measures CPU usage percentage. -Traces offer a granular, "under-the-hood" view of every agent and backend -operation. By providing a high-fidelity execution map, they enable precise -debugging of complex tool interactions and deep performance optimization. Each -trace captures rich, consistent metadata via custom span attributes: +
+Attributes -- `gen_ai.operation.name` (string): The high-level operation kind (e.g. - "tool_call", "llm_call"). -- `gen_ai.agent.name` (string): The service agent identifier ("gemini-cli"). -- `gen_ai.agent.description` (string): The service agent description. -- `gen_ai.input.messages` (string): Input messages or metadata specific to the - operation. -- `gen_ai.output.messages` (string): Output messages or metadata generated from - the operation. -- `gen_ai.request.model` (string): The request model name. -- `gen_ai.response.model` (string): The response model name. -- `gen_ai.system_instructions` (json string): The system instructions. -- `gen_ai.prompt.name` (string): The prompt name. -- `gen_ai.tool.name` (string): The executed tool's name. -- `gen_ai.tool.call_id` (string): The generated specific ID of the tool call. -- `gen_ai.tool.description` (string): The executed tool's description. -- `gen_ai.tool.definitions` (json string): The executed tool's description. -- `gen_ai.conversation.id` (string): The current CLI session ID. -- Additional user-defined Custom Attributes passed via the span's configuration. +- `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..adfe64d081 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` @@ -185,7 +187,7 @@ untrusted sources. ### Example custom theme -Custom theme example +Custom theme example ### Using your custom theme @@ -212,58 +214,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 fb1d8d48d2..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 @@ -40,19 +41,19 @@ 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 "Explain why this failed" +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 @@ -78,7 +79,7 @@ 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 ``` @@ -92,7 +93,7 @@ one. $newName = $_.Name -replace '\.py$', '.md' # Ask Gemini CLI to generate the documentation and print it to stdout - gemini "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8 + gemini -p "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8 } ``` @@ -214,7 +215,7 @@ 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" @@ -251,7 +252,7 @@ wrapper that writes the message for you. # Ask Gemini to write the message Write-Host "Generating commit message..." - $msg = $diff | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the 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" diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 8723a65892..76c2806f9d 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -89,7 +89,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 diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 37085569af..e464566c01 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -194,7 +194,7 @@ 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. +> not available when using "Sign in with Google". ## Creating custom subagents diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 46d43225b2..e6012f4d33 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -123,6 +123,7 @@ The manifest file defines the extension's behavior and configuration. }, "contextFileName": "GEMINI.md", "excludeTools": ["run_shell_command"], + "migratedTo": "https://github.com/new-owner/new-extension-repo", "plan": { "directory": ".gemini/plans" } @@ -138,6 +139,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 @@ -258,12 +262,14 @@ but lower priority than user or admin policies. ```toml [[rule]] -toolName = "my_server__dangerous_tool" +mcpName = "my_server" +toolName = "dangerous_tool" decision = "ask_user" priority = 100 [[safety_checker]] -toolName = "my_server__write_data" +mcpName = "my_server" +toolName = "write_data" priority = 200 [safety_checker.checker] type = "in-process" 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/get-started/authentication.md b/docs/get-started/authentication.md index bc603bbdf3..964e776567 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -17,8 +17,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) | @@ -36,7 +36,7 @@ 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 @@ -54,9 +54,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? @@ -391,7 +391,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/index.md b/docs/get-started/index.md index c516f90ac4..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. diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md index a750bc94b3..5242c3a13d 100644 --- a/docs/hooks/reference.md +++ b/docs/hooks/reference.md @@ -85,7 +85,7 @@ compared against the name of the tool being executed. `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/local-development.md b/docs/local-development.md index f710e3b00e..a31fa4aa11 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -1,23 +1,22 @@ # Local development guide This guide provides instructions for setting up and using local development -features, such as tracing. +features for Gemini CLI. ## Tracing -Traces are OpenTelemetry (OTel) records that help you debug your code by -instrumenting key events like model calls, tool scheduler operations, and tool -calls. +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. -Traces provide deep visibility into agent behavior and are invaluable for -debugging complex issues. They are captured automatically when telemetry is -enabled. +Traces provide deep visibility into agent behavior and help you debug complex +issues. They are captured automatically when you enable telemetry. -### Viewing traces +### View traces -You can view traces using either Jaeger or the Genkit Developer UI. +You can view traces using Genkit Developer UI, Jaeger, or Google Cloud. -#### Using Genkit +#### Use Genkit Genkit provides a web-based UI for viewing traces and other telemetry data. @@ -29,11 +28,8 @@ 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: - - ``` - Genkit Developer UI: http://localhost:4000 - ``` + The script will output the URL for the Genkit Developer UI. For example: + `Genkit Developer UI: http://localhost:4000` 2. **Run Gemini CLI:** @@ -48,21 +44,22 @@ 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 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:** @@ -77,16 +74,63 @@ You can view 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 traces +### Instrument code with traces -You can add 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: @@ -102,13 +146,13 @@ await runInDevTraceSpan( }, }, async ({ metadata }) => { - // The `metadata` object allows you to record the input and output of the + // 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 + // Your code to be traced goes here. try { const output = await somethingRisky(); metadata.output = output; diff --git a/docs/reference/commands.md b/docs/reference/commands.md index aafb8c8566..c7c25cba1e 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -71,9 +71,9 @@ Slash commands provide meta-level control over the CLI itself. [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` @@ -439,6 +439,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 6ab3acad35..13db1f0be3 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -105,7 +105,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 +147,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 @@ -297,7 +298,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): @@ -872,6 +873,11 @@ their corresponding top-level category object in your `settings.json` file. 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` @@ -998,6 +1004,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` @@ -1171,13 +1183,20 @@ 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. diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 7b396b73d4..f8aafa6502 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -8,122 +8,173 @@ 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` | -| Move the cursor to the end of the line. | `Ctrl+E`
`End` | -| Move the cursor up one line. | `Up` | -| Move the cursor down one line. | `Down` | -| Move the cursor one character to the left. | `Left` | -| Move the cursor one character to the right. | `Right`
`Ctrl+F` | -| Move the cursor one word to the left. | `Ctrl+Left`
`Alt+Left`
`Alt+B` | -| Move the cursor one word to the right. | `Ctrl+Right`
`Alt+Right`
`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/Win+Z`
`Alt+Z` | -| Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+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` | -| Scroll content down. | `Shift+Down` | -| 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` | -| Show the next entry in history. | `Ctrl+N` | -| Start reverse search through history. | `Ctrl+R` | -| Submit the selected reverse-search match. | `Enter` | -| Accept a suggestion while reverse searching. | `Tab` | -| 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` | -| Move selection down in lists. | `Down` | -| Move up within dialog options. | `Up`
`K` | -| Move down within dialog options. | `Down`
`J` | -| Move to the next item or question in a dialog. | `Tab` | -| 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`
`Enter` | -| Move to the previous completion option. | `Up`
`Ctrl+P` | -| Move to the next completion option. | `Down`
`Ctrl+N` | -| Expand an inline suggestion. | `Right` | -| Collapse an inline suggestion. | `Left` | +| 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` | -| Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | -| Open the current prompt or the plan in an external editor. | `Ctrl+X` | -| Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+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` | -| Show warning when trying to move focus away from background shell. | `Tab` | -| Show warning when trying to move focus away from shell input. | `Tab` | -| Move focus from Gemini to the active shell. | `Tab` | -| Move focus from the shell back to Gemini. | `Shift+Tab` | -| Clear the terminal screen and redraw the UI. | `Ctrl+L` | -| Restart the application. | `R`
`Shift+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 for commands by creating or modifying the +`keybindings.json` file in your home gemini directory (typically +`~/.gemini/keybindings.json`). This allows you to bind commands to additional +key combinations. Note that default keybindings cannot be removed. + +### 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": "input.submit", + "key": "cmd+s" + }, + { + "command": "edit.clear", + "key": "ctrl+l" + } +] +``` + +### Keyboard Rules + +- **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`. You must specify the exact modifier keys (`ctrl`, `shift`, + `alt`/`opt`/`option`, `cmd`/`meta`). +- **Literal Characters**: Terminals often translate complex key combinations + (especially on macOS with the `Option` key) into special characters. To handle + this reliably across all operating systems and SSH sessions, you can bind + directly to the literal character produced. For example, instead of trying to + bind `shift+5`, bind directly to `%`. +- **Special Keys**: Supported special keys include: + - **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 diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 38a0b4d50c..2ea23d4be4 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -76,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 @@ -164,8 +168,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 @@ -219,8 +223,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 @@ -297,7 +305,16 @@ 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** diff --git a/docs/release-confidence.md b/docs/release-confidence.md index f2dcccff4f..536e49772c 100644 --- a/docs/release-confidence.md +++ b/docs/release-confidence.md @@ -79,8 +79,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/resources/tos-privacy.md b/docs/resources/tos-privacy.md index 98d4a58b98..00de950e74 100644 --- a/docs/resources/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -46,7 +46,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 @@ -68,7 +68,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 @@ -84,7 +84,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 ea6341a0d6..3a7cd35b19 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. diff --git a/docs/sidebar.json b/docs/sidebar.json index 7c201e0071..e26004a973 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -111,7 +111,7 @@ "badge": "🔬", "slug": "docs/cli/notifications" }, - { "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" }, + { "label": "Plan mode", "slug": "docs/cli/plan-mode" }, { "label": "Subagents", "badge": "🔬", 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/mcp-server.md b/docs/tools/mcp-server.md index bbb5c62aba..6b8cd22ac0 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -555,21 +555,34 @@ 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 +708,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 ``` diff --git a/eslint.config.js b/eslint.config.js index d3a267f30a..a0a0429119 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,11 @@ const commonRestrictedSyntaxRules = [ message: 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', }, + { + selector: 'CallExpression[callee.name="fetch"]', + message: + 'Use safeFetch() from "@/utils/fetch" instead of the global fetch() to ensure SSRF protection. If you are implementing a custom security layer, use an eslint-disable comment and explain why.', + }, ]; export default tseslint.config( 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/img.png b/img.png new file mode 100644 index 0000000000..ab9f0bafcd Binary files /dev/null and b/img.png differ diff --git a/package-lock.json b/package-lock.json index a5437ac5c5..0dc1ce4951 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "workspaces": [ "packages/*" ], @@ -83,39 +83,6 @@ "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", @@ -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", @@ -1413,9 +1318,9 @@ } }, "node_modules/@google-cloud/storage": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", - "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.0.tgz", + "integrity": "sha512-5m9GoZqKh52a1UqkxDBu/+WVFDALNtHg5up5gNmNbXQWBcV813tzJKsyDtKjOPrlR1em1TxtD7NSPCrObH7koQ==", "license": "Apache-2.0", "dependencies": { "@google-cloud/paginator": "^5.0.0", @@ -1424,7 +1329,7 @@ "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", - "fast-xml-parser": "^5.3.4", + "fast-xml-parser": "^4.4.1", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", @@ -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": { @@ -2186,9 +2089,9 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3057,6 +2960,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", @@ -3466,9 +3375,9 @@ } }, "node_modules/@secretlint/config-loader/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", "dependencies": { @@ -3529,9 +3438,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": { @@ -3833,45 +3742,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", @@ -4414,20 +4284,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 +4308,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 +4324,18 @@ } }, "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 +4345,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 +4368,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 +4390,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 +4403,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 +4426,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 +4445,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 +4470,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 +4493,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 +4515,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 +4591,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 +4842,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 +4869,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 +5033,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 +5094,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": { @@ -5334,18 +5231,18 @@ } }, "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" } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", "dependencies": { @@ -5377,9 +5274,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5399,9 +5296,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" @@ -5750,11 +5647,13 @@ } }, "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/base64-js": { "version": "1.5.1", @@ -5863,14 +5762,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": { @@ -6132,9 +6032,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 +6045,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 +6060,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": { @@ -6235,9 +6135,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 +6147,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": { @@ -6325,26 +6225,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -6493,12 +6373,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", @@ -6570,13 +6444,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", @@ -6592,9 +6459,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { "node": ">=18" }, @@ -7118,29 +6982,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", @@ -7200,22 +7041,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", @@ -7452,9 +7277,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" @@ -7905,25 +7730,26 @@ } }, "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", @@ -8078,29 +7904,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", @@ -8111,22 +7914,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", @@ -8183,65 +7970,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" } @@ -8286,45 +8028,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", @@ -8479,9 +8182,9 @@ } }, "node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", @@ -8600,15 +8303,6 @@ "express": ">= 4.11" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -8740,9 +8434,9 @@ "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==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", "funding": [ { "type": "github", @@ -8751,7 +8445,7 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.2" + "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" @@ -8860,24 +8554,13 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8983,9 +8666,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": { @@ -9008,16 +8691,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", @@ -9212,9 +8885,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" @@ -9372,42 +9045,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", @@ -9454,9 +9091,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": { @@ -9683,10 +9320,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": { @@ -9702,6 +9351,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", @@ -9854,9 +9510,9 @@ } }, "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.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", "peer": true, "engines": { @@ -10219,9 +9875,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" @@ -10231,9 +9887,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" @@ -10264,13 +9920,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" @@ -10279,6 +9935,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", @@ -10580,18 +10248,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", @@ -11093,7 +10749,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { @@ -11409,9 +11064,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": { @@ -11450,9 +11105,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": { @@ -11467,14 +11122,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" @@ -12016,16 +11671,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": { @@ -12038,10 +11695,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" } @@ -12147,6 +11804,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", @@ -12174,45 +11851,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", @@ -12304,35 +11942,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", @@ -12492,29 +12101,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", @@ -12564,22 +12150,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", @@ -12646,9 +12216,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": { @@ -13048,21 +12618,6 @@ "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==", - "license": "MIT", - "dependencies": { - "is-network-error": "^1.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/package-json": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", @@ -13117,6 +12672,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", @@ -13293,21 +12860,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", @@ -13582,9 +13142,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": { @@ -13751,9 +13311,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" @@ -13945,6 +13505,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", @@ -13964,6 +13536,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", @@ -14124,13 +13708,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" }, @@ -14335,13 +13919,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": { @@ -14445,18 +14028,6 @@ "node": ">=6" } }, - "node_modules/run-jxa/node_modules/type-fest": { - "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": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -14619,9 +14190,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14885,9 +14456,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": { @@ -14910,9 +14481,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", @@ -14926,9 +14497,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" @@ -14938,12 +14509,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" @@ -15366,9 +14937,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": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", "funding": [ { "type": "github", @@ -15548,9 +15119,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.30.2", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.2.tgz", + "integrity": "sha512-Rrt5oFTWluUVuPlbtn3o9ja+nvjdF3Um4DG0KxqfYvpzcx7Q9plZBTjJiJy9mAouua4+OI7IUGBaG9Zyt9NgxA==", "license": "MIT", "os": [ "darwin", @@ -15591,9 +15162,9 @@ } }, "node_modules/table/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", "dependencies": { @@ -15677,22 +15248,6 @@ "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", - "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" - } - }, "node_modules/teeny-request": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", @@ -15766,59 +15321,20 @@ } }, "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", - "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" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -16063,9 +15579,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": { @@ -16193,12 +15709,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" @@ -16324,16 +15840,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" @@ -16343,8 +15858,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": { @@ -17044,9 +16559,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" @@ -17056,9 +16571,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": { @@ -17085,9 +16600,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" @@ -17160,18 +16675,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": { @@ -17326,9 +16838,9 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "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", @@ -17342,7 +16854,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", @@ -17356,6 +16868,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", @@ -17369,6 +16922,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", @@ -17384,12 +16953,12 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", "@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", @@ -17449,14 +17018,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" @@ -17465,16 +17053,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.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "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", @@ -17508,10 +17168,11 @@ "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", "marked": "^15.0.12", "mime": "4.0.7", @@ -17555,10 +17216,82 @@ "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", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -17571,14 +17304,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" }, @@ -17588,75 +17330,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", @@ -17666,12 +17339,6 @@ "node": ">= 4" } }, - "packages/core/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "packages/core/node_modules/mime": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", @@ -17687,24 +17354,6 @@ "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", @@ -17733,7 +17382,7 @@ }, "packages/devtools": { "name": "@google/gemini-cli-devtools", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "Apache-2.0", "dependencies": { "ws": "^8.16.0" @@ -17748,7 +17397,7 @@ }, "packages/sdk": { "name": "@google/gemini-cli-sdk", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17765,7 +17414,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17782,7 +17431,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", diff --git a/package.json b/package.json index 8d931c1462..0067054629 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "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.34.0-nightly.20260304.28af4e127" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index b70ea8986a..ecf3ee3d66 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.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "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 7fc35657fb..dbb8269376 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -252,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'; @@ -320,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}.`, @@ -457,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}.`, ); @@ -598,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..f9dda8a752 --- /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.getMessageBus(); + 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.getMessageBus(); + + // @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 e29f669333..26039ae3aa 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -4,25 +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 { GeminiEventType, - ApprovalMode, - ToolConfirmationOutcome, type Config, type ToolCallRequestInfo, type GitService, type CompletedToolCall, - type ToolCall, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; @@ -389,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 ef15a907e6..94a03171d7 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -5,7 +5,7 @@ */ import { - CoreToolScheduler, + Scheduler, type GeminiClient, GeminiEventType, ToolConfirmationOutcome, @@ -34,6 +34,8 @@ import { isSubagentProgress, EDIT_TOOL_NAMES, processRestorableToolCalls, + MessageBusType, + type ToolCallsUpdateMessage, } from '@google/gemini-cli-core'; import { type ExecutionEventBus, @@ -66,51 +68,33 @@ 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; @@ -127,7 +111,9 @@ export class Task { this.id = id; this.contextId = contextId; this.config = config; - this.scheduler = this.createScheduler(); + + this.scheduler = this.setupEventDrivenScheduler(); + this.geminiClient = this.config.getGeminiClient(); this.pendingToolConfirmationDetails = new Map(); this.taskState = 'submitted'; @@ -227,7 +213,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 { @@ -240,6 +226,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(); } @@ -252,7 +241,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, }; @@ -384,104 +373,153 @@ 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 messageBus = this.config.getMessageBus(); + 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})`), - ); + dispose(): void { + if (this.messageBusListener) { + this.config + .getMessageBus() + .unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusListener); + this.messageBusListener = undefined; + } - // Update state and send continuous, non-final updates - toolCalls.forEach((tc) => { - const previousStatus = this.pendingToolCalls.get(tc.request.callId); - const hasChanged = previousStatus !== tc.status; + this.scheduler.dispose(); + } - // 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.', - ); - 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); - } - } - }); + 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 }, @@ -489,18 +527,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< @@ -713,7 +746,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 { @@ -839,9 +881,15 @@ export class Task { ) { 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') { @@ -854,6 +902,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 { @@ -864,8 +914,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}`, ); @@ -887,24 +938,35 @@ 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) { + await this.config.getMessageBus().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) { @@ -920,6 +982,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. @@ -953,6 +1016,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; } @@ -1013,6 +1079,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, @@ -1034,6 +1101,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; @@ -1048,6 +1119,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, @@ -1093,7 +1165,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, @@ -1125,7 +1196,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/config/settings.ts b/packages/a2a-server/src/config/settings.ts index b3c44cc177..da9db4e069 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?: { diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 7262be42a8..4a883992b5 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -65,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/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 7d77d8dc9a..f63e66e85e 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -20,6 +20,7 @@ import { tmpdir, type Config, type Storage, + type ToolRegistry, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; import { expect, vi } from 'vitest'; @@ -30,6 +31,10 @@ export function createMockConfig( const tmpDir = tmpdir(); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const mockConfig = { + get toolRegistry(): ToolRegistry { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (this as unknown as Config).getToolRegistry(); + }, getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn(), getAllToolNames: vi.fn().mockReturnValue([]), diff --git a/packages/cli/package.json b/packages/cli/package.json index cc561eeb8c..648c4751e5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.34.0-nightly.20260304.28af4e127", + "version": "0.35.0-nightly.20260311.657f19c1f", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -26,12 +26,12 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f" }, "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", "@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", 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/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index aaaf667815..54534961dd 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -14,11 +14,16 @@ import { 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 = @@ -45,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) => ({ @@ -54,6 +61,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { })), { getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + getGlobalGeminiDir: () => '/tmp/gemini', }, ), GEMINI_DIR: '.gemini', @@ -96,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 = { @@ -265,7 +279,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'), @@ -304,4 +321,56 @@ describe('mcp list command', () => { ), ); }); + + 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 421c822a55..a1df1a8027 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,8 +18,13 @@ 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'; @@ -61,13 +69,13 @@ export async function getMcpServersFromConfig( async function testMCPConnection( serverName: string, config: MCPServerConfig, + isTrusted: boolean, + activeSettings: MergedSettings, ): Promise { - const settings = loadSettings(); - // 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 && !settings.isTrusted) { + if (isStdio && !isTrusted) { return MCPServerStatus.DISCONNECTED; } @@ -80,7 +88,7 @@ async function testMCPConnection( sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], - blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, + blockedEnvironmentVariables: activeSettings.advanced.excludedEnvVars, }, emitMcpDiagnostic: ( severity: 'info' | 'warning' | 'error', @@ -105,7 +113,7 @@ async function testMCPConnection( debugLogger.log(message, error); } }, - isTrustedFolder: () => settings.isTrusted, + isTrustedFolder: () => isTrusted, }; let transport; @@ -135,14 +143,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) { @@ -165,7 +199,12 @@ 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 = ''; @@ -178,6 +217,14 @@ export async function listMcpServers(settings?: MergedSettings): Promise { 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 = chalk.red('✗'); @@ -203,14 +250,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/config/config.ts b/packages/cli/src/config/config.ts index a8c85975e9..320e47a380 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -7,6 +7,7 @@ import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; +import * as path from 'node:path'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; @@ -33,6 +34,7 @@ import { getAdminErrorMessage, isHeadlessMode, Config, + resolveToRealPath, applyAdminAllowlist, getAdminBlockedMcpServersMessage, type HookDefinition, @@ -488,6 +490,15 @@ export async function loadCliConfig( const experimentalJitContext = settings.experimental?.jitContext ?? false; + let extensionRegistryURI: string | undefined = trustedFolder + ? settings.experimental?.extensionRegistryURI + : undefined; + if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) { + extensionRegistryURI = resolveToRealPath( + path.resolve(cwd, resolvePath(extensionRegistryURI)), + ); + } + let memoryContent: string | HierarchicalMemory = ''; let fileCount = 0; let filePaths: string[] = []; @@ -764,6 +775,7 @@ export async function loadCliConfig( deleteSession: argv.deleteSession, enabledExtensions: argv.extensions, extensionLoader: extensionManager, + extensionRegistryURI, enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts index a5fb822cdb..445f5ce485 100644 --- a/packages/cli/src/config/extension-manager.test.ts +++ b/packages/cli/src/config/extension-manager.test.ts @@ -345,4 +345,144 @@ describe('ExtensionManager', () => { } }); }); + + 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/); + }); + }); }); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 678350ba49..80c48193e2 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -129,6 +129,10 @@ export class ExtensionManager extends ExtensionLoader { this.requestSetting = options.requestSetting ?? undefined; } + getEnablementManager(): ExtensionEnablementManager { + return this.extensionEnablementManager; + } + setRequestConsent( requestConsent: (consent: string) => Promise, ): void { @@ -153,6 +157,7 @@ export class ExtensionManager extends ExtensionLoader { async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, + requestConsentOverride?: (consent: string) => Promise, ): Promise { if ( this.settings.security?.allowedExtensions && @@ -243,7 +248,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?`, @@ -271,17 +276,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( @@ -298,28 +314,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 }); @@ -392,6 +440,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, @@ -873,6 +933,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, diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index f8e66bf8e2..38264b285a 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, @@ -161,6 +162,7 @@ describe('extension tests', () => { beforeEach(() => { vi.clearAllMocks(); + resetSettingsCacheForTesting(); keychainData = {}; mockKeychainStorage = { getSecret: vi diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 04a7b885ca..564c4fbb6f 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -42,6 +42,10 @@ export interface ExtensionConfig { */ 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.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..76d7227ab4 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -287,6 +287,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/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..0141ffcc0e 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -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..cee50263bb 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -184,6 +184,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({ diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index bdb43e0975..b1139d7143 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -55,6 +55,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/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts deleted file mode 100644 index e450e68b71..0000000000 --- a/packages/cli/src/config/keyBindings.test.ts +++ /dev/null @@ -1,93 +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(); - }); - }); - - 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/settings.test.ts b/packages/cli/src/config/settings.test.ts index 5589ef11ba..7092f26a99 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 '{}'; }, @@ -523,11 +539,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 '{}'; }, @@ -576,11 +592,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 +615,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 +629,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 +661,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 +697,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 +717,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 +740,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 +772,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 +843,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 +903,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 +954,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 +1005,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 +1033,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 +1063,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 +1098,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 +1146,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 +1177,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 +1230,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 +1297,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 +1311,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 +1332,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 +1359,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 +1384,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 +1424,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 +1470,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 +1491,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 +1535,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 +1580,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 +1674,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 +1691,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 +1736,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 +1774,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 +1818,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 +1850,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 +1883,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 +2056,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 +2082,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 +2110,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 +2155,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 '{}'; }, @@ -2226,7 +2342,8 @@ describe('Settings Loading and Merging', () => { 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: { @@ -2235,7 +2352,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 '{}'; }, @@ -2270,10 +2387,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 '{}'; @@ -2343,7 +2460,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 '{}'; @@ -2394,7 +2511,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 '{}'; }, @@ -2430,13 +2547,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', () => { @@ -2445,14 +2565,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', () => { @@ -2463,7 +2588,7 @@ describe('Settings Loading and Merging', () => { }); const settingsFile = createMockSettings({}).user; - settingsFile.path = '/mock/settings.json'; + settingsFile.path = path.resolve('/mock/settings.json'); saveSettings(settingsFile); @@ -2491,7 +2616,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 '{}'; @@ -2538,7 +2663,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 '{}'; @@ -2579,7 +2704,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 '{}'; @@ -2694,7 +2819,7 @@ describe('Settings Loading and Merging', () => { beforeEach(() => { const emptySettingsFile: SettingsFile = { - path: '/mock/path', + path: path.resolve('/mock/path'), settings: {}, originalSettings: {}, }; @@ -3019,7 +3144,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 422dda6115..a195931803 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -18,6 +18,7 @@ import { coreEvents, homedir, type AdminControlsSettings, + createCache, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; @@ -615,6 +616,20 @@ 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(); +} + /** * Loads settings from user and workspace directories. * Project settings override user settings. @@ -622,6 +637,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 = {}; @@ -1029,6 +1054,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); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ceed54d288..cb68613f0f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -205,7 +205,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: [ @@ -307,10 +308,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', @@ -677,7 +678,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: { @@ -1497,6 +1498,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', @@ -1780,6 +1793,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', 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/core/auth.test.ts b/packages/cli/src/core/auth.test.ts index 5db9cd5449..639ed20a89 100644 --- a/packages/cli/src/core/auth.test.ts +++ b/packages/cli/src/core/auth.test.ts @@ -48,14 +48,14 @@ describe('auth', () => { }); 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).toEqual({ - authError: 'Failed to login. Message: Auth failed', + authError: 'Failed to sign in. Message: Authentication failed', accountSuspensionInfo: null, }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index f0b8015013..0bc89f5bda 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -64,7 +64,7 @@ export async function performInitialAuth( }; } return { - authError: `Failed to login. Message: ${getErrorMessage(e)}`, + authError: `Failed to sign in. Message: ${getErrorMessage(e)}`, accountSuspensionInfo: null, }; } diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 331ec0c018..4e95629908 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -92,6 +92,8 @@ 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 { @@ -109,6 +111,7 @@ 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; @@ -207,6 +210,11 @@ export async function startInteractiveUI( }); } + const { matchers, errors } = await loadKeyMatchers(); + errors.forEach((error) => { + coreEvents.emitFeedback('warning', error); + }); + const version = await getVersion(); setWindowTitle(basename(workspaceRoot), settings); @@ -229,35 +237,39 @@ export async function startInteractiveUI( return ( - - + - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); }; @@ -370,6 +382,7 @@ export async function main() { await Promise.all([ cleanupCheckpoints(), cleanupToolOutputFiles(settings.merged), + cleanupBackgroundLogs(), ]); const parseArgsHandle = startupProfiler.start('parse_arguments'); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index c2cab72353..c25e452ee0 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, diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 6eb27862e3..62154e3fed 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -142,6 +142,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; @@ -163,6 +171,9 @@ describe('BuiltinCommandLoader', () => { getAllSkills: vi.fn().mockReturnValue([]), isAdminEnabled: vi.fn().mockReturnValue(true), }), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: 'other', + }), } as unknown as Config; restoreCommandMock.mockReturnValue({ @@ -172,6 +183,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); @@ -364,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 8ee5effc59..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'; @@ -59,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 @@ -223,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/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts new file mode 100644 index 0000000000..15a2ebec18 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -0,0 +1,125 @@ +/** + * @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' }); + }); +}); diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts new file mode 100644 index 0000000000..85f1884299 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -0,0 +1,53 @@ +/** + * @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, + 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/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 3ff65c4067..a9aea95376 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -36,7 +36,10 @@ import { 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 { @@ -171,6 +174,7 @@ export class AppRig { async initialize() { this.setupEnvironment(); + resetSettingsCacheForTesting(); this.settings = this.createRigSettings(); const approvalMode = diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index c8ab45a35d..86479dda89 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -125,7 +125,7 @@ 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({}), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0b6eaa037b..13550d3f42 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -2770,7 +2770,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 @@ -2792,6 +2792,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. @@ -3145,7 +3200,7 @@ describe('AppContainer State Management', () => { }); }); - it('clears the prompt when onCancelSubmit is called with shouldRestorePrompt=false', async () => { + it('preserves buffer when cancelling, even if empty (user is in control)', async () => { let unmount: () => void; await act(async () => { const result = renderAppContainer(); @@ -3161,7 +3216,45 @@ describe('AppContainer State Management', () => { onCancelSubmit(false); }); - expect(mockSetText).toHaveBeenCalledWith(''); + // Should NOT modify buffer when cancelling - user is in control + expect(mockSetText).not.toHaveBeenCalled(); + + 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(), + }); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + 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!(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 67f2d5dd84..03e001546b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -119,7 +119,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'; @@ -164,7 +164,7 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.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 +205,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 +220,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()!; @@ -470,9 +473,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(); @@ -1217,8 +1222,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) { @@ -1226,7 +1238,7 @@ Logging in with Google... Restarting Gemini CLI to continue. clearQueue(); } - if (textToSet || !shouldRestorePrompt) { + if (textToSet) { buffer.setText(textToSet); } }, @@ -1386,11 +1398,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({ @@ -1654,7 +1662,7 @@ Logging in with Google... Restarting Gemini CLI to continue. debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } - if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) { + if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } @@ -1848,13 +1856,26 @@ Logging in with Google... Restarting Gemini CLI to continue. 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; diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index da8b43dd20..b8de6adb0b 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -103,7 +103,7 @@ describe('ApiAuthDialog', () => { it.each([ { - keyName: 'return', + keyName: 'enter', sequence: '\r', expectedCall: onSubmit, args: ['submitted-key'], diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx index 2caad6fd27..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; diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index c157a6a40d..7ab5fc0be2 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -209,7 +209,7 @@ 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(); @@ -351,7 +351,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') diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 58956e5f86..4e523d6b11 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -44,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, }, diff --git a/packages/cli/src/ui/auth/AuthInProgress.test.tsx b/packages/cli/src/ui/auth/AuthInProgress.test.tsx index 7f279a1067..bd6a3cb126 100644 --- a/packages/cli/src/ui/auth/AuthInProgress.test.tsx +++ b/packages/cli/src/ui/auth/AuthInProgress.test.tsx @@ -59,8 +59,8 @@ describe('AuthInProgress', () => { , ); 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(); }); 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/LoginWithGoogleRestartDialog.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx index 94ca359b59..a781828d09 100644 --- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx @@ -45,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__/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 20a02ffb21..f236428ff1 100644 --- a/packages/cli/src/ui/auth/useAuth.test.tsx +++ b/packages/cli/src/ui/auth/useAuth.test.tsx @@ -288,7 +288,7 @@ describe('useAuth', () => { ); await waitFor(() => { - expect(result.current.authError).toContain('Failed to login'); + expect(result.current.authError).toContain('Failed to sign in'); 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 afd438bb00..809a3b34b8 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -149,7 +149,7 @@ export const useAuthCommand = ( // Show the error message directly without "Failed to login" prefix onAuthError(getErrorMessage(e)); } else { - onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); + onAuthError(`Failed to sign in. Message: ${getErrorMessage(e)}`); } } })(); diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts index ba1e369b14..88e3273c8d 100644 --- a/packages/cli/src/ui/commands/authCommand.test.ts +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -34,11 +34,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 +61,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' diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 0314555baf..80c432894c 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(); diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index d33dc5884d..05bbe2d852 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -17,6 +17,7 @@ vi.mock('@google/gemini-cli-core', async () => { ...actual, uiTelemetryService: { setLastPromptTokenCount: vi.fn(), + clear: vi.fn(), }, }; }); @@ -74,17 +75,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]; @@ -110,8 +110,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..2ae2609204 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -23,10 +23,6 @@ export const clearCommand: SlashCommand = { action: async (context, _args) => { const geminiClient = context.services.config?.getGeminiClient(); const config = context.services.config; - const chatRecordingService = context.services.config - ?.getGeminiClient() - ?.getChat() - .getChatRecordingService(); // Fire SessionEnd hook before clearing const hookSystem = config?.getHookSystem(); @@ -34,6 +30,18 @@ export const clearCommand: SlashCommand = { await hookSystem.fireSessionEndEvent(SessionEndReason.Clear); } + // Reset user steering hints + config?.userHintService.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 +51,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 +67,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/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 89147a1b90..d1c2ede5e8 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -475,14 +475,18 @@ describe('extensionsCommand', () => { mockInstallExtension.mockResolvedValue({ name: extension.url }); // Call onSelect - component.props.onSelect?.(extension); + await component.props.onSelect?.(extension); await waitFor(() => { expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: extension.url, - type: 'git', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: extension.url, + type: 'git', + }, + undefined, + undefined, + ); }); expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); @@ -622,10 +626,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}"...`, @@ -647,10 +655,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}`, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 051d337019..6693d36b18 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -279,9 +279,9 @@ async function exploreAction( return { type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { - onSelect: (extension) => { + onSelect: async (extension, requestConsentOverride) => { debugLogger.log(`Selected extension: ${extension.extensionName}`); - void installAction(context, extension.url); + await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, onClose: () => context.ui.removeComponent(), @@ -458,7 +458,11 @@ async function enableAction(context: CommandContext, args: string) { } } -async function installAction(context: CommandContext, args: string) { +async function installAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { const extensionLoader = context.services.config?.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( @@ -505,8 +509,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.`, diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index a125b1eda4..c583db394a 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -120,6 +120,7 @@ async function downloadFiles({ downloads.push( (async () => { const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`; + // eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection const response = await fetch(endpoint, { method: 'GET', dispatcher: proxy ? new ProxyAgent(proxy) : undefined, diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts index cfb6d4368e..1e5b0feb90 100644 --- a/packages/cli/src/ui/commands/toolsCommand.test.ts +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -67,7 +67,7 @@ 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: { @@ -88,6 +88,27 @@ 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: { + config: { + getToolRegistry: () => ({ 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: { @@ -105,9 +126,65 @@ 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: { + config: { + getToolRegistry: () => ({ 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: { + config: { + getToolRegistry: () => ({ 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.'); }); diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 6a26d4f3d6..082da26fab 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -41,7 +41,16 @@ async function listTools( context.ui.addItem(toolsListItem); } -const toolsDescSubCommand: SlashCommand = { +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.', @@ -57,11 +66,11 @@ export const toolsCommand: SlashCommand = { 'List available Gemini CLI tools. Use /tools desc to include descriptions.', kind: CommandKind.BUILT_IN, autoExecute: false, - subCommands: [toolsDescSubCommand], + subCommands: [listSubCommand, descSubCommand], action: async (context: CommandContext, args?: string): Promise => { const subCommand = args?.trim(); - // Keep backward compatibility for typed arguments while exposing desc in TUI via subcommands. + // Keep backward compatibility for typed arguments while exposing subcommands in TUI. const useShowDescriptions = subCommand === 'desc' || subCommand === 'descriptions'; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index e4f0d0ad52..28f52461e4 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -182,6 +182,7 @@ export enum CommandKind { EXTENSION_FILE = 'extension-file', MCP_PROMPT = 'mcp-prompt', AGENT = 'agent', + SKILL = 'skill', } // The standardized contract for any command in the system. 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..d511f69c3a --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.test.ts @@ -0,0 +1,118 @@ +/** + * @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: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + }, + }, + } 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.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(); + }); +}); diff --git a/packages/cli/src/ui/commands/upgradeCommand.ts b/packages/cli/src/ui/commands/upgradeCommand.ts new file mode 100644 index 0000000000..e863d8ee73 --- /dev/null +++ b/packages/cli/src/ui/commands/upgradeCommand.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + openBrowserSecurely, + shouldLaunchBrowser, + UPGRADE_URL_PAGE, +} from '@google/gemini-cli-core'; +import type { SlashCommand } from './types.js'; +import { CommandKind } 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 authType = + context.services.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.', + }; + } + + 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/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx index b7a615a18f..3f1226b651 100644 --- a/packages/cli/src/ui/components/AboutBox.test.tsx +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -36,7 +36,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(); }); @@ -63,7 +63,7 @@ describe('AboutBox', () => { ); 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(); }); 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.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..52cda094e0 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -327,5 +327,31 @@ 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 } = render( + + + , + ); + 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 4079c6df77..819b32d7b0 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx @@ -110,6 +110,8 @@ interface AgentConfigDialogProps { settings: LoadedSettings; onClose: () => void; onSave?: () => void; + /** Available terminal height for dynamic windowing */ + availableTerminalHeight?: number; } /** @@ -192,6 +194,7 @@ export function AgentConfigDialog({ settings, onClose, onSave, + availableTerminalHeight, }: AgentConfigDialogProps): React.JSX.Element { // Scope selector state (User by default) const [selectedScope, setSelectedScope] = useState( @@ -395,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/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index b9601e772a..0b15f917a6 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -17,16 +17,30 @@ 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 ICON = `▝▜▄ +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(); @@ -39,6 +53,8 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { settings.merged.ui.hideBanner || config.getScreenReader() ); + const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON; + if (!showDetails) { return ( 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..c16febea66 --- /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 = renderWithProviders(); + await result.waitUntilReady(); + + await expect(result).toMatchSvgSnapshot(); + }); + + it('renders the symmetric icon in Apple Terminal', async () => { + vi.mocked(isAppleTerminal).mockReturnValue(true); + + const result = renderWithProviders(); + await result.waitUntilReady(); + + await expect(result).toMatchSvgSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx index 4eaf3f18a4..7e8f388c82 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -8,8 +8,8 @@ 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 '../utils/keybindingUtils.js'; -import { Command } from '../../config/keyBindings.js'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { Command } from '../key/keyBindings.js'; interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 284e4e1df8..4233616144 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -20,10 +20,10 @@ 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 { Command } from '../key/keyMatchers.js'; import { checkExhaustive } from '@google/gemini-cli-core'; import { TextInput } from './shared/TextInput.js'; -import { formatCommand } from '../utils/keybindingUtils.js'; +import { formatCommand } from '../key/keybindingUtils.js'; import { useTextBuffer, expandPastePlaceholders, @@ -36,6 +36,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; @@ -208,6 +209,7 @@ const ReviewView: React.FC = ({ progressHeader, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const unansweredCount = questions.length - Object.keys(answers).length; const hasUnanswered = unansweredCount > 0; @@ -288,6 +290,7 @@ const TextQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const prefix = '> '; const horizontalPadding = 1; // 1 for cursor @@ -325,7 +328,7 @@ const TextQuestionView: React.FC = ({ } return false; }, - [buffer, textValue], + [buffer, textValue, keyMatchers], ); useKeypress(handleExtraKeys, { isActive: true, priority: true }); @@ -487,6 +490,7 @@ const ChoiceQuestionView: React.FC = ({ progressHeader, keyboardHints, }) => { + const keyMatchers = useKeyMatchers(); const isAlternateBuffer = useAlternateBuffer(); const numOptions = (question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0); @@ -680,6 +684,7 @@ const ChoiceQuestionView: React.FC = ({ customBuffer, onEditingCustomOption, customOptionText, + keyMatchers, ], ); @@ -802,16 +807,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 ( @@ -819,7 +829,7 @@ const ChoiceQuestionView: React.FC = ({ {progressHeader} @@ -950,6 +960,7 @@ export const AskUserDialog: React.FC = ({ availableHeight: availableHeightProp, extraParts, }) => { + const keyMatchers = useKeyMatchers(); const uiState = useContext(UIStateContext); const availableHeight = availableHeightProp ?? @@ -999,7 +1010,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [onCancel, submitted, isEditingCustomOption], + [onCancel, submitted, isEditingCustomOption, keyMatchers], ); useKeypress(handleCancel, { @@ -1032,7 +1043,7 @@ export const AskUserDialog: React.FC = ({ } return false; }, - [questions.length, submitted, goToNextTab, goToPrevTab], + [questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers], ); useKeypress(handleNavigation, { diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index 4d37de24c3..847dcd9a87 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'), }, }; }); @@ -222,7 +226,7 @@ describe('', () => { expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 76, - 21, + 20, ); rerender( @@ -242,7 +246,7 @@ describe('', () => { expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 96, - 27, + 26, ); unmount(); }); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index 16093ef0d7..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; @@ -452,6 +482,7 @@ export const BackgroundShellDisplay = ({ {isListOpenProp ? renderProcessList() : renderOutput()} + {renderFooter()} ); }; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index b1f804dd42..84f8d15a06 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -831,7 +831,7 @@ describe('Composer', () => { expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint'); }); - it('does not show shortcuts hint immediately when buffer has text', async () => { + it('hides shortcuts hint when text is typed in buffer', async () => { const uiState = createMockUIState({ buffer: { text: 'hello' } as unknown as TextBuffer, cleanUiDetailsVisible: false, @@ -901,16 +901,6 @@ describe('Composer', () => { expect(lastFrame()).not.toContain('ShortcutsHint'); }); - it('hides shortcuts hint when text is typed in buffer', async () => { - const uiState = createMockUIState({ - buffer: { text: 'hello' } as unknown as TextBuffer, - }); - - 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, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d30f52dddf..0864b8f02b 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -171,10 +171,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { return () => clearTimeout(timeout); }, [canShowShortcutsHint]); + const shouldReserveSpaceForShortcutsHint = + settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions; const showShortcutsHint = - settings.merged.ui.showShortcutsHint && - !hideShortcutsHintForSuggestions && - showShortcutsHintDebounced; + shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced; const showMinimalModeBleedThrough = !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; @@ -187,7 +187,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { !showUiDetails && (showMinimalInlineLoading || showMinimalBleedThroughRow || - showShortcutsHint); + shouldReserveSpaceForShortcutsHint); return ( { marginTop={isNarrow ? 1 : 0} flexDirection="column" alignItems={isNarrow ? 'flex-start' : 'flex-end'} + minHeight={ + showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0 + } > {showUiDetails && showShortcutsHint && } @@ -304,11 +307,13 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} - {(showMinimalContextBleedThrough || showShortcutsHint) && ( + {(showMinimalContextBleedThrough || + shouldReserveSpaceForShortcutsHint) && ( {showMinimalContextBleedThrough && ( { terminalWidth={uiState.terminalWidth} /> )} - {showShortcutsHint && ( - - - - )} + + {showShortcutsHint && } + )} diff --git a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx index b6fb8ce1b6..7f09d46491 100644 --- a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx +++ b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx @@ -210,7 +210,7 @@ export const ConfigExtensionDialog: React.FC = ({ 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/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx index de7cb3a888..6f202ced4a 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.test.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx @@ -35,7 +35,8 @@ describe('CopyModeWarning', () => { const { lastFrame, waitUntilReady, unmount } = render(); await waitUntilReady(); 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/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 5119c1b343..de62401e1e 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -252,6 +252,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 diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 2bf1f723a6..33daca1e33 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -10,7 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.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 { Command } from '../key/keyMatchers.js'; import { ApprovalMode, validatePlanContent, @@ -18,6 +18,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(), @@ -402,6 +403,7 @@ Implement a comprehensive authentication system with multiple providers. }: { children: React.ReactNode; }) => { + const keyMatchers = useKeyMatchers(); useKeypress( (key) => { if (keyMatchers[Command.QUIT](key)) { diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 39e1b8a155..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); @@ -247,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/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 21aa6ee5c0..ab487a440f 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -101,6 +101,12 @@ describe('