diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 95cd3488c9..bde7ae31ab 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,7 +1,5 @@ name: 'Bug Report' description: 'Report a bug to help us improve Gemini CLI' -labels: - - 'status/need-triage' body: - type: 'markdown' attributes: diff --git a/.github/scripts/pr-triage.sh b/.github/scripts/pr-triage.sh index 2052406869..cca12747b7 100755 --- a/.github/scripts/pr-triage.sh +++ b/.github/scripts/pr-triage.sh @@ -68,7 +68,7 @@ process_pr_optimized() { fi else echo " ⚠️ No linked issue found for PR #${PR_NUMBER}" - if [[ ",${CURRENT_LABELS}," != ",status/need-issue,"* ]]; then + if [[ ",${CURRENT_LABELS}," != *",status/need-issue,"* ]]; then echo " βž• Adding status/need-issue label" LABELS_TO_ADD="status/need-issue" fi @@ -82,7 +82,7 @@ process_pr_optimized() { else echo " πŸ”— Found linked issue #${ISSUE_NUMBER}" - if [[ ",${CURRENT_LABELS}," == ",status/need-issue,"* ]]; then + if [[ ",${CURRENT_LABELS}," == *",status/need-issue,"* ]]; then echo " βž– Removing status/need-issue label" LABELS_TO_REMOVE="status/need-issue" fi diff --git a/.github/workflows/label-enforcer.yml b/.github/workflows/label-enforcer.yml index 173a80c103..98b8a3f554 100644 --- a/.github/workflows/label-enforcer.yml +++ b/.github/workflows/label-enforcer.yml @@ -10,7 +10,7 @@ jobs: enforce-label: # Run this job only when restricted labels are changed if: |- - ${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage') && + ${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage' || github.event.label.name == 'πŸ”’ maintainer only') && (github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') }} runs-on: 'ubuntu-latest' permissions: @@ -39,28 +39,34 @@ jobs: const labelName = context.payload.label.name; // Skip if the change was made by a bot to avoid infinite loops - if (username === 'github-actions[bot]') { + if (username === 'github-actions[bot]' || username === 'gemini-cli[bot]' || username.endsWith('[bot]')) { core.info('Change made by a bot. Skipping.'); return; } try { - // This will succeed with a 204 status if the user is a member, - // and fail with a 404 error if they are not. - await github.rest.teams.getMembershipForUserInOrg ({ - org, - team_slug, + // Check repository permission level directly. + // This is more robust than team membership as it doesn't require Org-level read permissions + // and correctly handles Repo Admins/Writers who might not be in the specific team. + const { data: { permission } } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: org, + repo: context.repo.repo, username, }); - core.info(`${username} is a member of the ${team_slug} team. No action needed.`); - } catch (error) { - // If the error is not 404, rethrow it to fail the action - if (error.status !== 404) { - throw error; + + if (permission === 'admin' || permission === 'write') { + core.info(`${username} has '${permission}' permission. Allowed.`); + return; } - core.info(`${username} is not a member. Reverting '${action}' action for '${labelName}' label.`); + core.info(`${username} has '${permission}' permission (needs 'write' or 'admin'). Reverting '${action}' action for '${labelName}' label.`); + } catch (error) { + core.error(`Failed to check permissions for ${username}: ${error.message}`); + // Fall through to revert logic if we can't verify permissions (fail safe) + } + // If we are here, the user is NOT authorized. + if (true) { // wrapping block to preserve variable scope if needed if (action === 'labeled') { // 1. Remove the label if added by a non-maintainer await github.rest.issues.removeLabel ({ diff --git a/.github/workflows/label-workstream-rollup.yml b/.github/workflows/label-workstream-rollup.yml new file mode 100644 index 0000000000..38707bac7b --- /dev/null +++ b/.github/workflows/label-workstream-rollup.yml @@ -0,0 +1,94 @@ +name: 'Label Workstream Rollup' + +on: + issues: + types: ['opened', 'edited', 'reopened'] + schedule: + - cron: '0 * * * *' + workflow_dispatch: + +jobs: + labeler: + runs-on: 'ubuntu-latest' + permissions: + issues: 'write' + steps: + - name: 'Check for Parent Workstream and Apply Label' + uses: 'actions/github-script@v7' + with: + script: | + const labelToAdd = 'workstream-rollup'; + + // Allow-list of parent issue URLs + const allowedParentUrls = [ + 'https://github.com/google-gemini/gemini-cli/issues/15374', + 'https://github.com/google-gemini/gemini-cli/issues/15456', + 'https://github.com/google-gemini/gemini-cli/issues/15324' + ]; + + async function getIssueParent(owner, repo, number) { + const query = ` + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + issue(number:$number) { + parent { + url + } + } + } + } + `; + try { + const result = await github.graphql(query, { owner, repo, number }); + return result.repository.issue.parent ? result.repository.issue.parent.url : null; + } catch (error) { + console.error(`Failed to fetch parent for #${number}:`, error); + return null; + } + } + + // Determine which issues to process + let issuesToProcess = []; + + if (context.eventName === 'issues') { + // Context payload for 'issues' event already has the issue object + issuesToProcess.push({ + number: context.payload.issue.number, + owner: context.repo.owner, + repo: context.repo.repo + }); + } else { + // For schedule/dispatch, fetch open issues (lightweight list) + console.log(`Running for event: ${context.eventName}. Fetching open issues...`); + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open' + }); + issuesToProcess = openIssues.map(i => ({ + number: i.number, + owner: context.repo.owner, + repo: context.repo.repo + })); + } + + console.log(`Processing ${issuesToProcess.length} issue(s)...`); + + for (const issue of issuesToProcess) { + const parentUrl = await getIssueParent(issue.owner, issue.repo, issue.number); + + if (parentUrl && allowedParentUrls.includes(parentUrl)) { + console.log(`SUCCESS: Issue #${issue.number} is a direct child of ${parentUrl}. Adding label.`); + await github.rest.issues.addLabels({ + owner: issue.owner, + repo: issue.repo, + issue_number: issue.number, + labels: [labelToAdd] + }); + } else { + // logging only for single execution to avoid spam + if (context.eventName === 'issues') { + console.log(`Issue #${issue.number} parent is ${parentUrl || 'None'}. No action.`); + } + } + } diff --git a/GEMINI.md b/GEMINI.md index 3da20efb75..1752c32a20 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -15,6 +15,17 @@ project. While you can run the individual steps (`build`, `test`, `typecheck`, `lint`) separately, it is highly recommended to use `npm run preflight` to ensure a comprehensive validation. +## Running Tests in Workspaces\*\*: To run a specific test file within a + +workspace, use the command: +`npm test -w -- `. **CRITICAL**: The +`` MUST be relative to the workspace directory root, +NOT the project root. + +- _Example (Core package)_: + `npm test -w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts` +- _Common workspaces_: `@google/gemini-cli`, `@google/gemini-cli-core`. + ## Writing Tests This project uses **Vitest** as its primary testing framework. When writing diff --git a/docs/changelogs/releases.md b/docs/changelogs/releases.md index 90b94140c3..23524cfc3c 100644 --- a/docs/changelogs/releases.md +++ b/docs/changelogs/releases.md @@ -1230,7 +1230,7 @@ https://github.com/google-gemini/gemini-cli/compare/v0.19.4...v0.20.0 https://github.com/google-gemini/gemini-cli/pull/12863 - feat(hooks): Hook Telemetry Infrastructure by @Edilmo in https://github.com/google-gemini/gemini-cli/pull/9082 -- fix: (some minor improvements to configs and getPackageJson return behaviour) +- fix: (some minor improvements to configs and getPackageJson return behavior) by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in https://github.com/google-gemini/gemini-cli/pull/12510 - feat(hooks): Hook Event Handling by @Edilmo in @@ -1364,7 +1364,7 @@ https://github.com/google-gemini/gemini-cli/compare/v0.18.4...v0.19.0 https://github.com/google-gemini/gemini-cli/pull/12863 - feat(hooks): Hook Telemetry Infrastructure by @Edilmo in https://github.com/google-gemini/gemini-cli/pull/9082 -- fix: (some minor improvements to configs and getPackageJson return behaviour) +- fix: (some minor improvements to configs and getPackageJson return behavior) by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in https://github.com/google-gemini/gemini-cli/pull/12510 - feat(hooks): Hook Event Handling by @Edilmo in diff --git a/docs/cli/commands.md b/docs/cli/commands.md index fb5da33133..886a3d4669 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -204,6 +204,23 @@ Slash commands provide meta-level control over the CLI itself. modify them as desired. Changes to some settings are applied immediately, while others require a restart. +- [**`/skills`**](./skills.md) + - **Description:** (Experimental) Manage Agent Skills, which provide on-demand + expertise and specialized workflows. + - **Sub-commands:** + - **`list`**: + - **Description:** List all discovered skills and their current status + (enabled/disabled). + - **`enable`**: + - **Description:** Enable a specific skill by name. + - **Usage:** `/skills enable ` + - **`disable`**: + - **Description:** Disable a specific skill by name. + - **Usage:** `/skills disable ` + - **`reload`**: + - **Description:** Refresh the list of discovered skills from all tiers + (workspace, user, and extensions). + - **`/stats`** - **Description:** Display detailed statistics for the current Gemini CLI session, including token usage, cached token savings (when available), and diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index 2d251fc373..b70be823f1 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -50,7 +50,7 @@ Your command definition files must be written in the TOML format and use the ## Handling arguments Custom commands support two powerful methods for handling arguments. The CLI -automatically chooses the correct method based on the content of your command\'s +automatically chooses the correct method based on the content of your command's `prompt`. ### 1. Context-aware injection with `{{args}}` @@ -96,13 +96,13 @@ Search Results: """ ``` -When you run `/grep-code It\'s complicated`: +When you run `/grep-code It's complicated`: 1. The CLI sees `{{args}}` used both outside and inside `!{...}`. -2. Outside: The first `{{args}}` is replaced raw with `It\'s complicated`. +2. Outside: The first `{{args}}` is replaced raw with `It's complicated`. 3. Inside: The second `{{args}}` is replaced with the escaped version (e.g., on Linux: `"It\'s complicated"`). -4. The command executed is `grep -r "It\'s complicated" .`. +4. The command executed is `grep -r "It's complicated" .`. 5. The CLI prompts you to confirm this exact, secure command before execution. 6. The final prompt is sent. @@ -129,13 +129,13 @@ format and behavior. # In: /.gemini/commands/changelog.toml # Invoked via: /changelog 1.2.0 added "Support for default argument parsing." -description = "Adds a new entry to the project\'s CHANGELOG.md file." +description = "Adds a new entry to the project's CHANGELOG.md file." prompt = """ # Task: Update Changelog You are an expert maintainer of this software project. A user has invoked a command to add a new entry to the changelog. -**The user\'s raw command is appended below your instructions.** +**The user's raw command is appended below your instructions.** Your task is to parse the ``, ``, and `` from their input and use the `write_file` tool to correctly update the `CHANGELOG.md` file. @@ -147,7 +147,7 @@ The command follows this format: `/changelog ` 1. Read the `CHANGELOG.md` file. 2. Find the section for the specified ``. 3. Add the `` under the correct `` heading. -4. If the version or type section doesn\'t exist, create it. +4. If the version or type section doesn't exist, create it. 5. Adhere strictly to the "Keep a Changelog" format. """ ``` @@ -241,7 +241,7 @@ operate on specific files. **Example (`review.toml`):** This command injects the content of a _fixed_ best practices file -(`docs/best-practices.md`) and uses the user\'s arguments to provide context for +(`docs/best-practices.md`) and uses the user's arguments to provide context for the review. ```toml @@ -293,7 +293,7 @@ practice. description = "Asks the model to refactor the current context into a pure function." prompt = """ -Please analyze the code I\'ve provided in the current context. +Please analyze the code I've provided in the current context. Refactor it into a pure function. Your response should include: diff --git a/docs/cli/index.md b/docs/cli/index.md index 069c802411..4c5f7eac8a 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -25,10 +25,12 @@ overview of Gemini CLI, see the [main documentation page](../index.md). - **[Checkpointing](./checkpointing.md):** Automatically save and restore snapshots of your session and files. -- **[Enterprise configuration](./enterprise.md):** Deploying and manage Gemini - CLI in an enterprise environment. +- **[Enterprise configuration](./enterprise.md):** Deploy and manage Gemini CLI + in an enterprise environment. - **[Sandboxing](./sandbox.md):** Isolate tool execution in a secure, containerized environment. +- **[Agent Skills](./skills.md):** (Experimental) Extend the CLI with + specialized expertise and procedural workflows. - **[Telemetry](./telemetry.md):** Configure observability to monitor usage and performance. - **[Token caching](./token-caching.md):** Optimize API costs by caching tokens. diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 16a4083584..3a6abf14f7 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -8,10 +8,12 @@ available combinations. #### Basic Controls -| Action | Keys | -| -------------------------------------------- | ------- | -| Confirm the current selection or choice. | `Enter` | -| Dismiss dialogs or cancel the current focus. | `Esc` | +| Action | Keys | +| --------------------------------------------------------------- | ---------- | +| Confirm the current selection or choice. | `Enter` | +| Dismiss dialogs or cancel the current focus. | `Esc` | +| 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` | #### Cursor Movement @@ -40,12 +42,6 @@ available combinations. | Undo the most recent text edit. | `Ctrl + Z (no Shift)` | | Redo the most recent undone text edit. | `Ctrl + Shift + Z` | -#### Screen Control - -| Action | Keys | -| -------------------------------------------- | ---------- | -| Clear the terminal screen and redraw the UI. | `Ctrl + L` | - #### Scrolling | Action | Keys | @@ -88,39 +84,28 @@ available combinations. #### Text Input -| Action | Keys | -| ------------------------------------ | ---------------------------------------------------------------------- | -| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd)` | -| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Shift + Enter`
`Ctrl + J` | - -#### External Tools - -| Action | Keys | -| ---------------------------------------------- | ------------------------- | -| Open the current prompt in an external editor. | `Ctrl + X` | -| Paste from the clipboard. | `Ctrl + V`
`Cmd + V` | +| Action | Keys | +| ---------------------------------------------- | ---------------------------------------------------------------------- | +| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd)` | +| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Shift + Enter`
`Ctrl + J` | +| Open the current prompt in an external editor. | `Ctrl + X` | +| Paste from the clipboard. | `Ctrl + V`
`Cmd + 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. | `Cmd + M` | -| Toggle copy mode when the terminal is using the alternate buffer. | `Ctrl + S` | -| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | -| Toggle Auto Edit (auto-accept edits) mode. | `Shift + Tab` | -| Expand a height-constrained response to show additional lines. | `Ctrl + S` | -| Focus the shell input from the gemini input. | `Tab (no Shift)` | -| Focus the Gemini input from the shell input. | `Tab` | - -#### Session Control - -| Action | Keys | -| -------------------------------------------- | ---------- | -| Cancel the current request or quit the CLI. | `Ctrl + C` | -| Exit the CLI when the input buffer is empty. | `Ctrl + D` | +| Action | Keys | +| ------------------------------------------------------------------------------------------------ | ---------------- | +| Toggle detailed error information. | `F12` | +| Toggle the full TODO list. | `Ctrl + T` | +| Show IDE context details. | `Ctrl + G` | +| Toggle Markdown rendering. | `Cmd + M` | +| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` | +| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | +| Toggle Auto Edit (auto-accept edits) mode. | `Shift + Tab` | +| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + S` | +| Focus the shell input from the gemini input. | `Tab (no Shift)` | +| Focus the Gemini input from the shell input. | `Tab` | +| Clear the terminal screen and redraw the UI. | `Ctrl + L` | diff --git a/docs/cli/model-routing.md b/docs/cli/model-routing.md index 15105a4ef8..1f833d3f6e 100644 --- a/docs/cli/model-routing.md +++ b/docs/cli/model-routing.md @@ -11,7 +11,7 @@ health and automatically routes requests to available models based on defined policies. 1. **Model failure:** If the currently selected model fails (e.g., due to quota - or server errors), the CLI will iniate the fallback process. + or server errors), the CLI will initiate the fallback process. 2. **User consent:** Depending on the failure and the model's policy, the CLI may prompt you to switch to a fallback model (by default always prompts diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 09bb8f76d3..28b54851c2 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -11,7 +11,7 @@ Before using sandboxing, you need to install and set up the Gemini CLI: npm install -g @google/gemini-cli ``` -To verify the installation +To verify the installation: ```bash gemini --version diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index df59b2fcff..40f234335d 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -224,11 +224,11 @@ visualize your telemetry. This dashboard can be found under **Google Cloud Monitoring Dashboard Templates** as "**Gemini CLI Monitoring**". -![Gemini CLI Monitoring Dashboard Overview](../assets/monitoring-dashboard-overview.png) +![Gemini CLI Monitoring Dashboard Overview](/docs/assets/monitoring-dashboard-overview.png) -![Gemini CLI Monitoring Dashboard Metrics](../assets/monitoring-dashboard-metrics.png) +![Gemini CLI Monitoring Dashboard Metrics](/docs/assets/monitoring-dashboard-metrics.png) -![Gemini CLI Monitoring Dashboard Logs](../assets/monitoring-dashboard-logs.png) +![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/). diff --git a/docs/extensions/getting-started-extensions.md b/docs/extensions/getting-started-extensions.md index 8f174a2966..04e5987c85 100644 --- a/docs/extensions/getting-started-extensions.md +++ b/docs/extensions/getting-started-extensions.md @@ -222,9 +222,45 @@ need this for extensions built to expose commands and prompts. Restart the CLI again. The model will now have the context from your `GEMINI.md` file in every session where the extension is active. -## Step 6: Releasing your extension +## (Optional) Step 6: Add an Agent Skill -Once you are happy with your extension, you can share it with others. The two +_Note: This is an experimental feature enabled via `experimental.skills`._ + +[Agent Skills](../cli/skills.md) let you bundle specialized expertise and +procedural workflows. Unlike `GEMINI.md`, which provides persistent context, +skills are activated only when needed, saving context tokens. + +1. Create a `skills` directory and a subdirectory for your skill: + + ```bash + mkdir -p skills/security-audit + ``` + +2. Create a `skills/security-audit/SKILL.md` file: + + ```markdown + --- + name: security-audit + description: + Expertise in auditing code for security vulnerabilities. Use when the user + asks to "check for security issues" or "audit" their changes. + --- + + # Security Auditor + + You are an expert security researcher. When auditing code: + + 1. Look for common vulnerabilities (OWASP Top 10). + 2. Check for hardcoded secrets or API keys. + 3. Suggest remediation steps for any findings. + ``` + +Skills bundled with your extension are automatically discovered and can be +activated by the model during a session when it identifies a relevant task. + +## Step 7: Release your extension + +Once you're happy with your extension, you can share it with others. The two primary ways of releasing extensions are via a Git repository or through GitHub Releases. Using a public Git repository is the simplest method. @@ -239,6 +275,7 @@ You've successfully created a Gemini CLI extension! You learned how to: - Add custom tools with an MCP server. - Create convenient custom commands. - Provide persistent context to the model. +- Bundle specialized Agent Skills. - Link your extension for local development. From here, you can explore more advanced features and build powerful new diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 25c24c7f21..8f71d1c184 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -2,10 +2,10 @@ _This documentation is up-to-date with the v0.4.0 release._ -Gemini CLI extensions package prompts, MCP servers, and custom commands into a -familiar and user-friendly format. With extensions, you can expand the -capabilities of Gemini CLI and share those capabilities with others. They are -designed to be easily installable and shareable. +Gemini CLI extensions package prompts, MCP servers, Agent Skills, and custom +commands into a familiar and user-friendly format. With extensions, you can +expand the capabilities of Gemini CLI and share those capabilities with others. +They're designed to be easily installable and shareable. To see examples of extensions, you can browse a gallery of [Gemini CLI extensions](https://geminicli.com/extensions/browse/). @@ -263,6 +263,40 @@ Would provide these commands: - `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help - `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help +### Agent Skills + +_Note: This is an experimental feature enabled via `experimental.skills`._ + +Extensions can bundle [Agent Skills](../cli/skills.md) to provide on-demand +expertise and specialized workflows. To include skills in your extension, place +them in a `skills/` subdirectory within the extension directory. Each skill must +follow the [Agent Skills structure](../cli/skills.md#folder-structure), +including a `SKILL.md` file. + +**Example** + +An extension named `security-toolkit` with the following structure: + +``` +.gemini/extensions/security-toolkit/ +β”œβ”€β”€ gemini-extension.json +└── skills/ + β”œβ”€β”€ audit/ + β”‚ β”œβ”€β”€ SKILL.md + β”‚ └── scripts/ + β”‚ └── scan.py + └── hardening/ + └── SKILL.md +``` + +Upon installation, these skills will be discovered by Gemini CLI and can be +activated during a session when the model identifies a task matching their +descriptions. + +Extension skills have the lowest precedence and will be overridden by user or +workspace skills of the same name. They can be viewed and managed (enabled or +disabled) using the [`/skills` command](../cli/skills.md#managing-skills). + ### Hooks Extensions can provide [hooks](../hooks/index.md) to intercept and customize diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index d0ae56041c..de9c6db5e7 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -1346,6 +1346,10 @@ for that specific session. - `auto_edit`: Automatically approve edit tools (replace, write_file) while prompting for others - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`) + - `plan`: Read-only mode for tool calls (requires experimental planning to + be enabled). + > **Note:** This mode is currently under development and not yet fully + > functional. - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. - Example: `gemini --approval-mode auto_edit` diff --git a/docs/index.md b/docs/index.md index 83e834818e..217fba8391 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,6 +56,8 @@ This documentation is organized into the following sections: commands with `/model`. - **[Sandbox](./cli/sandbox.md):** Isolate tool execution in a secure, containerized environment. +- **[Agent Skills](./cli/skills.md):** (Experimental) Extend the CLI with + specialized expertise and procedural workflows. - **[Settings](./cli/settings.md):** Configure various aspects of the CLI's behavior and appearance with `/settings`. - **[Telemetry](./cli/telemetry.md):** Overview of telemetry in the CLI. diff --git a/docs/local-development.md b/docs/local-development.md index 11cbbae139..e194307eae 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -10,7 +10,7 @@ debug your code by instrumenting interesting events like model calls, tool scheduler, tool calls, etc. Dev traces are verbose and are specifically meant for understanding agent -behaviour and debugging issues. They are disabled by default. +behavior and debugging issues. They are disabled by default. To enable dev traces, set the `GEMINI_DEV_TRACING=true` environment variable when running Gemini CLI. diff --git a/docs/tools/index.md b/docs/tools/index.md index 68a7f5826a..0434046ac4 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -91,5 +91,8 @@ Additionally, these tools incorporate: - **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the Gemini model and your local environment or other services like APIs. +- **[Agent Skills](../cli/skills.md)**: (Experimental) On-demand expertise + packages that are activated via the `activate_skill` tool to provide + specialized guidance and resources. - **[Sandboxing](../cli/sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2dea1c7212..515099934a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -28,6 +28,16 @@ topics on: - **Organizational Users:** Contact your Google Cloud administrator to be 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.`** + - **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) + - Google AI Pro and Ultra where Gemini Code Assist (and Gemini CLI) is also + available: + [Available locations](https://developers.google.com/gemini-code-assist/resources/locations-pro-ultra) + - **Error: `Failed to login. 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 @@ -137,7 +147,8 @@ This is especially useful for scripting and automation. - **Core debugging:** - Check the server console output for error messages or stack traces. - - Increase log verbosity if configurable. + - Increase log verbosity if configurable. For example, set the `DEBUG_MODE` + environment variable to `true` or `1`. - Use Node.js debugging tools (e.g., `node --inspect`) if you need to step through server-side code. diff --git a/evals/README.md b/evals/README.md index 891a9549f5..962f54886c 100644 --- a/evals/README.md +++ b/evals/README.md @@ -88,6 +88,13 @@ describe('my_feature', () => { ## Running Evaluations +First, build the bundled Gemini CLI. You must do this after every code change. + +```bash +npm run build +npm run bundle +``` + ### Always Passing Evals To run the evaluations that are expected to always pass (CI safe): diff --git a/evals/subagents.eval.ts b/evals/subagents.eval.ts new file mode 100644 index 0000000000..d0c77d4fe7 --- /dev/null +++ b/evals/subagents.eval.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe } from 'vitest'; +import { evalTest } from './test-helper.js'; + +const AGENT_DEFINITION = `--- +name: docs-agent +description: An agent with expertise in updating documentation. +tools: + - read_file + - write_file +--- + +You are the docs agent. Update the documentation. +`; + +const INDEX_TS = 'export const add = (a: number, b: number) => a + b;'; + +describe('subagent eval test cases', () => { + /** + * Checks whether the outer agent reliably utilizes an expert subagent to + * accomplish a task when one is available. + * + * Note that the test is intentionally crafted to avoid the word "document" + * or "docs". We want to see the outer agent make the connection even when + * the prompt indirectly implies need of expertise. + * + * This tests the system prompt's subagent specific clauses. + */ + evalTest('ALWAYS_PASSES', { + name: 'should delegate to user provided agent with relevant expertise', + params: { + settings: { + experimental: { + enableAgents: true, + }, + }, + }, + prompt: 'Please update README.md with a description of this library.', + files: { + '.gemini/agents/test-agent.md': AGENT_DEFINITION, + 'index.ts': INDEX_TS, + 'README.md': 'TODO: update the README.', + }, + assert: async (rig, _result) => { + await rig.expectToolCallSuccess( + ['delegate_to_agent'], + undefined, + (args) => { + try { + const parsed = JSON.parse(args); + return parsed.agent_name === 'docs-agent'; + } catch { + return false; + } + }, + ); + }, + }); +}); diff --git a/evals/test-helper.ts b/evals/test-helper.ts index 9801d2307b..7fc9589986 100644 --- a/evals/test-helper.ts +++ b/evals/test-helper.ts @@ -6,7 +6,10 @@ import { it } from 'vitest'; import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; import { TestRig } from '@google/gemini-cli-test-utils'; +import { createUnauthorizedToolError } from '@google/gemini-cli-core'; export * from '@google/gemini-cli-test-utils'; @@ -32,8 +35,33 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { const fn = async () => { const rig = new TestRig(); try { - await rig.setup(evalCase.name, evalCase.params); + rig.setup(evalCase.name, evalCase.params); + + if (evalCase.files) { + for (const [filePath, content] of Object.entries(evalCase.files)) { + const fullPath = path.join(rig.testDir!, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + + const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const }; + execSync('git init', execOptions); + execSync('git config user.email "test@example.com"', execOptions); + execSync('git config user.name "Test User"', execOptions); + execSync('git add .', execOptions); + execSync('git commit --allow-empty -m "Initial commit"', execOptions); + } + const result = await rig.run({ args: evalCase.prompt }); + + const unauthorizedErrorPrefix = + createUnauthorizedToolError('').split("'")[0]; + if (result.includes(unauthorizedErrorPrefix)) { + throw new Error( + 'Test failed due to unauthorized tool call in output: ' + result, + ); + } + await evalCase.assert(rig, result); } finally { await logToFile( @@ -44,7 +72,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { } }; - if (policy === 'USUALLY_PASSES' && !process.env.RUN_EVALS) { + if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) { it.skip(evalCase.name, fn); } else { it(evalCase.name, fn); @@ -55,6 +83,7 @@ export interface EvalCase { name: string; params?: Record; prompt: string; + files?: Record; assert: (rig: TestRig, result: string) => Promise; } diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/simple-mcp-server.test.ts index 1de8007810..6db9927616 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/simple-mcp-server.test.ts @@ -164,7 +164,7 @@ rpc.send({ }); `; -describe('simple-mcp-server', () => { +describe.skip('simple-mcp-server', () => { let rig: TestRig; beforeEach(() => { diff --git a/package-lock.json b/package-lock.json index 56b985c5e7..7d487301e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "workspaces": [ "packages/*" ], @@ -2474,6 +2474,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2654,6 +2655,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2687,6 +2689,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3055,6 +3058,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3088,6 +3092,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3140,6 +3145,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4352,6 +4358,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4629,6 +4636,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5633,6 +5641,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6077,8 +6086,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -7362,7 +7370,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8682,6 +8689,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -9284,7 +9292,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -9294,7 +9301,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9304,7 +9310,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9558,7 +9563,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9577,7 +9581,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9586,15 +9589,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -10877,6 +10878,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz", "integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -14061,8 +14063,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -14639,6 +14640,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14649,6 +14651,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16908,6 +16911,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17131,7 +17135,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -17139,6 +17144,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17322,6 +17328,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17484,7 +17491,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -17539,6 +17545,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17652,6 +17659,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17664,6 +17672,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18368,6 +18377,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18383,7 +18393,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "dependencies": { "@a2a-js/sdk": "^0.3.7", "@google-cloud/storage": "^7.16.0", @@ -18693,7 +18703,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", @@ -18704,6 +18714,7 @@ "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "clipboardy": "^5.0.0", + "color-convert": "^2.0.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^7.0.0", @@ -18796,7 +18807,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "^0.3.7", @@ -18933,6 +18944,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18955,7 +18967,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -18972,7 +18984,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", diff --git a/package.json b/package.json index e4602937ba..3434bb8e49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "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.26.0-nightly.20260114.bb6c57414" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0" }, "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 91bca79c21..d8d42b3cc8 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.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "description": "Gemini CLI A2A Server", "repository": { "type": "git", diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index f427bdfe63..4eb6b522b2 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -14,7 +14,7 @@ import type { TaskStatusUpdateEvent, SendStreamingMessageSuccessResponse, } from '@a2a-js/sdk'; -import type express from 'express'; +import express from 'express'; import type { Server } from 'node:http'; import request from 'supertest'; import { @@ -27,7 +27,7 @@ import { it, vi, } from 'vitest'; -import { createApp } from './app.js'; +import { createApp, main } from './app.js'; import { commandRegistry } from '../commands/command-registry.js'; import { assertUniqueFinalEventIsLast, @@ -1176,4 +1176,43 @@ describe('E2E Tests', () => { }); }); }); + + describe('main', () => { + it('should listen on localhost only', async () => { + const listenSpy = vi + .spyOn(express.application, 'listen') + .mockImplementation((...args: unknown[]) => { + // Trigger the callback passed to listen + const callback = args.find( + (arg): arg is () => void => typeof arg === 'function', + ); + if (callback) { + callback(); + } + + return { + address: () => ({ port: 1234 }), + on: vi.fn(), + once: vi.fn(), + emit: vi.fn(), + } as unknown as Server; + }); + + // Avoid process.exit if possible, or mock it if main might fail + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + await main(); + + expect(listenSpy).toHaveBeenCalledWith( + expect.any(Number), + 'localhost', + expect.any(Function), + ); + + listenSpy.mockRestore(); + exitSpy.mockRestore(); + }); + }); }); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 8d7be4f7a1..4b5763f00b 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -326,9 +326,9 @@ export async function createApp() { export async function main() { try { const expressApp = await createApp(); - const port = process.env['CODER_AGENT_PORT'] || 0; + const port = Number(process.env['CODER_AGENT_PORT'] || 0); - const server = expressApp.listen(port, () => { + const server = expressApp.listen(port, 'localhost', () => { const address = server.address(); let actualPort; if (process.env['CODER_AGENT_PORT']) { diff --git a/packages/cli/package.json b/packages/cli/package.json index 2f8e5ec8c2..3007ecd5b0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.26.0-nightly.20260114.bb6c57414", + "version": "0.26.0-nightly.20260115.6cb3ae4e0", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -26,7 +26,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260114.bb6c57414" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.26.0-nightly.20260115.6cb3ae4e0" }, "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", @@ -37,6 +37,7 @@ "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "clipboardy": "^5.0.0", + "color-convert": "^2.0.1", "command-exists": "^1.2.9", "comment-json": "^4.2.5", "diff": "^7.0.0", diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts index 36a1344f74..008997d4fe 100644 --- a/packages/cli/src/commands/hooks/migrate.ts +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -230,8 +230,7 @@ export async function handleMigrateFromClaude() { const settings = loadSettings(workingDir); // Merge migrated hooks with existing hooks - const existingHooks = - (settings.merged.hooks as Record) || {}; + const existingHooks = settings.merged.hooks as Record; const mergedHooks = { ...existingHooks, ...migratedHooks }; // Update settings (setValue automatically saves) diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 7d78d48233..fed9fb6a5c 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -6,15 +6,20 @@ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { listMcpServers } from './list.js'; -import { loadSettings } from '../../config/settings.js'; +import { loadSettings, mergeSettings } 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'; -vi.mock('../../config/settings.js', () => ({ - loadSettings: vi.fn(), -})); +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); vi.mock('../../config/extensions/storage.js', () => ({ ExtensionStorage: { getUserExtensionsDir: vi.fn(), @@ -32,11 +37,16 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { CONNECTING: 'CONNECTING', DISCONNECTED: 'DISCONNECTED', }, - Storage: vi.fn().mockImplementation((_cwd: string) => ({ - getGlobalSettingsPath: () => '/tmp/gemini/settings.json', - getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json', - getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash', - })), + Storage: Object.assign( + vi.fn().mockImplementation((_cwd: string) => ({ + getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json', + getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash', + })), + { + getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + }, + ), GEMINI_DIR: '.gemini', getErrorMessage: (e: unknown) => e instanceof Error ? e.message : String(e), @@ -96,7 +106,10 @@ describe('mcp list command', () => { }); it('should display message when no servers configured', async () => { - mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } }); + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { ...defaultMergedSettings, mcpServers: {} }, + }); await listMcpServers(); @@ -104,8 +117,10 @@ describe('mcp list command', () => { }); it('should display different server types with connected status', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { + ...defaultMergedSettings, mcpServers: { 'stdio-server': { command: '/path/to/server', args: ['arg1'] }, 'sse-server': { url: 'https://example.com/sse' }, @@ -138,8 +153,10 @@ describe('mcp list command', () => { }); it('should display disconnected status when connection fails', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { + ...defaultMergedSettings, mcpServers: { 'test-server': { command: '/test/server' }, }, @@ -158,9 +175,13 @@ describe('mcp list command', () => { }); it('should merge extension servers with config servers', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { - mcpServers: { 'config-server': { command: '/config/server' } }, + ...defaultMergedSettings, + mcpServers: { + 'config-server': { command: '/config/server' }, + }, }, }); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index b41baec960..27a25fec4a 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -35,7 +35,7 @@ async function getMcpServersFromConfig(): Promise< requestSetting: promptForSetting, }); const extensions = await extensionManager.loadExtensions(); - const mcpServers = { ...(settings.merged.mcpServers || {}) }; + const mcpServers = { ...settings.merged.mcpServers }; for (const extension of extensions) { Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { if (mcpServers[key]) { @@ -63,8 +63,7 @@ async function testMCPConnection( const sanitizationConfig = { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], - blockedEnvironmentVariables: - settings.merged.advanced?.excludedEnvVars || [], + blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, }; let transport; diff --git a/packages/cli/src/config/config.integration.test.ts b/packages/cli/src/config/config.integration.test.ts index 49afb1ae5b..6797be4447 100644 --- a/packages/cli/src/config/config.integration.test.ts +++ b/packages/cli/src/config/config.integration.test.ts @@ -22,8 +22,9 @@ import { Config, DEFAULT_FILE_FILTERING_OPTIONS, } from '@google/gemini-cli-core'; -import type { Settings } from './settingsSchema.js'; +import { createTestMergedSettings } from './settings.js'; import { http, HttpResponse } from 'msw'; + import { setupServer } from 'msw/node'; export const server = setupServer(); @@ -212,7 +213,7 @@ describe('Configuration Integration Tests', () => { const originalArgv = process.argv; try { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.approvalMode).toBe(expected.approvalMode); expect(parsedArgs.prompt).toBe(expected.prompt); expect(parsedArgs.yolo).toBe(expected.yolo); @@ -235,7 +236,9 @@ describe('Configuration Integration Tests', () => { const originalArgv = process.argv; try { process.argv = argv; - await expect(parseArguments({} as Settings)).rejects.toThrow(); + await expect( + parseArguments(createTestMergedSettings()), + ).rejects.toThrow(); } finally { process.argv = originalArgv; } diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 78a4847fd2..59d1f4d906 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -19,8 +19,9 @@ import { ApprovalMode, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; -import type { Settings } from './settings.js'; +import { type Settings, createTestMergedSettings } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; + import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionManager } from './extension-manager.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; @@ -189,7 +190,7 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -222,7 +223,7 @@ describe('parseArguments', () => { }, ])('$description', async ({ argv, expected }) => { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.prompt).toBe(expected.prompt); expect(parsedArgs.promptInteractive).toBe(expected.promptInteractive); }); @@ -344,7 +345,7 @@ describe('parseArguments', () => { '$description', async ({ argv, expectedQuery, expectedModel, debug }) => { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.query).toBe(expectedQuery); expect(parsedArgs.prompt).toBe(expectedQuery); expect(parsedArgs.promptInteractive).toBeUndefined(); @@ -380,7 +381,7 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -408,7 +409,7 @@ describe('parseArguments', () => { }, ])('$description', async ({ argv, expected }) => { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.approvalMode).toBe(expected.approvalMode); expect(parsedArgs.yolo).toBe(expected.yolo); }); @@ -427,7 +428,7 @@ describe('parseArguments', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -447,7 +448,7 @@ describe('parseArguments', () => { process.argv = ['node', 'script.js', '--resume', 'session-id']; try { - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.resume).toBe('session-id'); } finally { process.stdin.isTTY = originalIsTTY; @@ -460,7 +461,7 @@ describe('parseArguments', () => { process.argv = ['node', 'script.js', '--resume']; try { - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.resume).toBe(RESUME_LATEST); expect(argv.resume).toBe('latest'); } finally { @@ -475,7 +476,7 @@ describe('parseArguments', () => { '--allowed-tools', 'read_file,ShellTool(git status)', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.allowedTools).toEqual(['read_file', 'ShellTool(git status)']); }); @@ -486,13 +487,13 @@ describe('parseArguments', () => { '--allowed-mcp-server-names', 'server1,server2', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.allowedMcpServerNames).toEqual(['server1', 'server2']); }); it('should support comma-separated values for --extensions', async () => { process.argv = ['node', 'script.js', '--extensions', 'ext1,ext2']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.extensions).toEqual(['ext1', 'ext2']); }); @@ -504,7 +505,7 @@ describe('parseArguments', () => { 'test-model-string', 'my-positional-arg', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.model).toBe('test-model-string'); expect(argv.query).toBe('my-positional-arg'); }); @@ -521,7 +522,7 @@ describe('parseArguments', () => { '--allowed-tools=ShellTool(wc)', 'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.extensions).toEqual(['none']); expect(argv.approvalMode).toBe('auto_edit'); expect(argv.allowedTools).toEqual([ @@ -576,8 +577,8 @@ describe('loadCliConfig', () => { it(`should leave proxy to empty by default`, async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBeFalsy(); }); @@ -617,8 +618,8 @@ describe('loadCliConfig', () => { it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => { vi.stubEnv(input.env_name, input.proxy_url); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBe(expected); }); @@ -627,8 +628,8 @@ describe('loadCliConfig', () => { it('should use default fileFilter options when unconfigured', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFileFilteringRespectGitIgnore()).toBe( DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, @@ -653,7 +654,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { it('should pass extension context file paths to loadServerHierarchicalMemory', async () => { process.argv = ['node', 'script.js']; - const settings: Settings = {}; + const settings = createTestMergedSettings(); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -683,7 +684,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { isActive: true, }, ]); - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), @@ -693,24 +694,24 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { expect.any(ExtensionManager), true, 'tree', - { - respectGitIgnore: false, + expect.objectContaining({ + respectGitIgnore: true, respectGeminiIgnore: true, - }, - undefined, // maxDirs + }), + 200, // maxDirs ); }); }); describe('mergeMcpServers', () => { it('should not modify the original settings object', async () => { - const settings: Settings = { + const settings = createTestMergedSettings({ mcpServers: { 'test-server': { url: 'http://localhost:8080', }, }, - }; + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { @@ -730,7 +731,7 @@ describe('mergeMcpServers', () => { ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); @@ -755,7 +756,9 @@ describe('mergeExcludeTools', () => { }); it('should merge excludeTools from settings and extensions', async () => { - const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -777,7 +780,7 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( settings, @@ -791,7 +794,9 @@ describe('mergeExcludeTools', () => { }); it('should handle overlapping excludeTools between settings and extensions', async () => { - const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -804,7 +809,7 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3']), @@ -813,7 +818,9 @@ describe('mergeExcludeTools', () => { }); it('should handle overlapping excludeTools between extensions', async () => { - const settings: Settings = { tools: { exclude: ['tool1'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -835,7 +842,7 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3', 'tool4']), @@ -845,26 +852,28 @@ describe('mergeExcludeTools', () => { it('should return an empty array when no excludeTools are specified and it is interactive', async () => { process.stdin.isTTY = true; - const settings: Settings = {}; + const settings = createTestMergedSettings(); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set([])); }); it('should return default excludes when no excludeTools are specified and it is not interactive', async () => { process.stdin.isTTY = false; - const settings: Settings = {}; + const settings = createTestMergedSettings(); process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(defaultExcludes); }); it('should handle settings with excludeTools but no extensions', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); @@ -872,7 +881,7 @@ describe('mergeExcludeTools', () => { }); it('should handle extensions with excludeTools but no settings', async () => { - const settings: Settings = {}; + const settings = createTestMergedSettings(); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', @@ -885,14 +894,16 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); expect(config.getExcludeTools()).toHaveLength(2); }); it('should not modify the original settings object', async () => { - const settings: Settings = { tools: { exclude: ['tool1'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', @@ -906,7 +917,7 @@ describe('mergeExcludeTools', () => { ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); @@ -930,8 +941,8 @@ describe('Approval mode tool exclusion logic', () => { it('should exclude all interactive tools in non-interactive mode with default approval mode', async () => { process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); @@ -949,8 +960,8 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -969,8 +980,8 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -989,8 +1000,8 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1000,10 +1011,34 @@ describe('Approval mode tool exclusion logic', () => { expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); }); + it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'plan', + '-p', + 'test', + ]; + const settings = createTestMergedSettings({ + experimental: { + plan: true, + }, + }); + const argv = await parseArguments(createTestMergedSettings()); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).toContain(SHELL_TOOL_NAME); + expect(excludedTools).toContain(EDIT_TOOL_NAME); + expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME); + }); + it('should exclude no interactive tools in non-interactive mode with legacy yolo flag', async () => { process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1026,8 +1061,8 @@ describe('Approval mode tool exclusion logic', () => { for (const testCase of testCases) { process.argv = testCase.args; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1047,8 +1082,10 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { exclude: ['custom_tool'] } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + tools: { exclude: ['custom_tool'] }, + }); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1061,12 +1098,12 @@ describe('Approval mode tool exclusion logic', () => { it('should throw an error if YOLO mode is attempted when disableYoloMode is true', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { disableYoloMode: true, }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode since it is disabled by your admin', @@ -1082,11 +1119,11 @@ describe('Approval mode tool exclusion logic', () => { yolo: false, }; - const settings: Settings = {}; + const settings = createTestMergedSettings(); await expect( loadCliConfig(settings, 'test-session', invalidArgv as CliArgs), ).rejects.toThrow( - 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, default', + 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, plan, default', ); }); }); @@ -1104,17 +1141,17 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { vi.restoreAllMocks(); }); - const baseSettings: Settings = { + const baseSettings = createTestMergedSettings({ mcpServers: { server1: { url: 'http://localhost:8080' }, server2: { url: 'http://localhost:8081' }, server3: { url: 'http://localhost:8082' }, }, - }; + }); it('should allow all MCP servers if the flag is not provided', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual(baseSettings.mcpServers); }); @@ -1126,7 +1163,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server1', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1']); }); @@ -1140,7 +1177,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server3', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server3']); }); @@ -1154,50 +1191,50 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server4', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server4']); }); it('should allow no MCP servers if the flag is provided but empty', async () => { process.argv = ['node', 'script.js', '--allowed-mcp-server-names', '']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['']); }); it('should read allowMCPServers from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { allowed: ['server1', 'server2'] }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); }); it('should read excludeMCPServers from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1', 'server2'] }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getBlockedMcpServers()).toEqual(['server1', 'server2']); }); it('should override allowMCPServers with excludeMCPServers if overlapping', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1'], allowed: ['server1', 'server2'], }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); expect(config.getBlockedMcpServers()).toEqual(['server1']); @@ -1210,14 +1247,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server1', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1'], allowed: ['server2'], }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1']); }); @@ -1231,14 +1268,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server3', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { allowed: ['server1', 'server2'], // Should be ignored excluded: ['server3'], // Should be ignored }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server2', 'server3']); expect(config.getBlockedMcpServers()).toEqual([]); @@ -1256,13 +1293,13 @@ describe('loadCliConfig model selection', () => { it('selects a model from settings.json if provided', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ model: { name: 'gemini-2.5-pro', }, - }, + }), 'test-session', argv, ); @@ -1272,11 +1309,11 @@ describe('loadCliConfig model selection', () => { it('uses the default gemini model if nothing is set', async () => { process.argv = ['node', 'script.js']; // No model set. - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ // No model set. - }, + }), 'test-session', argv, ); @@ -1286,13 +1323,13 @@ describe('loadCliConfig model selection', () => { it('always prefers model from argv', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ model: { name: 'gemini-2.5-pro', }, - }, + }), 'test-session', argv, ); @@ -1302,11 +1339,11 @@ describe('loadCliConfig model selection', () => { it('selects the model from argv if provided', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ // No model provided via settings. - }, + }), 'test-session', argv, ); @@ -1316,11 +1353,11 @@ describe('loadCliConfig model selection', () => { it('selects the default auto model if provided via auto alias', async () => { process.argv = ['node', 'script.js', '--model', 'auto']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ // No model provided via settings. - }, + }), 'test-session', argv, ); @@ -1344,36 +1381,36 @@ describe('loadCliConfig folderTrust', () => { it('should be false when folderTrust is false', async () => { process.argv = ['node', 'script.js']; - const settings: Settings = { + const settings = createTestMergedSettings({ security: { folderTrust: { enabled: false, }, }, - }; - const argv = await parseArguments({} as Settings); + }); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); it('should be true when folderTrust is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { folderTrust: { enabled: true, }, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(true); }); it('should be false by default', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); @@ -1404,8 +1441,8 @@ describe('loadCliConfig with includeDirectories', () => { '--include-directories', `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ context: { includeDirectories: [ path.resolve(path.sep, 'settings', 'path1'), @@ -1413,7 +1450,7 @@ describe('loadCliConfig with includeDirectories', () => { path.join(mockCwd, 'settings', 'path3'), ], }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); const expected = [ mockCwd, @@ -1449,22 +1486,22 @@ describe('loadCliConfig compressionThreshold', () => { it('should pass settings to the core config', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ model: { compressionThreshold: 0.5, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(await config.getCompressionThreshold()).toBe(0.5); }); - it('should have undefined compressionThreshold if not in settings', async () => { + it('should have default compressionThreshold if not in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); - expect(await config.getCompressionThreshold()).toBeUndefined(); + expect(await config.getCompressionThreshold()).toBe(0.5); }); }); @@ -1483,24 +1520,24 @@ describe('loadCliConfig useRipgrep', () => { it('should be true by default when useRipgrep is not set in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); it('should be false when useRipgrep is set to false in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { useRipgrep: false } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ tools: { useRipgrep: false } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(false); }); it('should be true when useRipgrep is explicitly set to true in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { useRipgrep: true } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ tools: { useRipgrep: true } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); @@ -1521,38 +1558,38 @@ describe('screenReader configuration', () => { it('should use screenReader value from settings if CLI flag is not present (settings true)', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: true } }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); it('should use screenReader value from settings if CLI flag is not present (settings false)', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: false } }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); it('should prioritize --screen-reader CLI flag (true) over settings (false)', async () => { process.argv = ['node', 'script.js', '--screen-reader']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: false } }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); it('should be false by default when no flag or setting is present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); @@ -1582,8 +1619,12 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in interactive mode without YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1592,8 +1633,12 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in interactive mode with YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1602,8 +1647,12 @@ describe('loadCliConfig tool exclusions', () => { it('should exclude interactive tools in non-interactive mode without YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).toContain('run_shell_command'); expect(config.getExcludeTools()).toContain('replace'); expect(config.getExcludeTools()).toContain('write_file'); @@ -1612,8 +1661,12 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in non-interactive mode with YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1629,16 +1682,24 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', 'ShellTool', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); it('should exclude web-fetch in non-interactive mode when not allowed', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).toContain(WEB_FETCH_TOOL_NAME); }); @@ -1652,8 +1713,12 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', WEB_FETCH_TOOL_NAME, ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME); }); @@ -1667,8 +1732,12 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', 'run_shell_command', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); @@ -1682,8 +1751,12 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', 'ShellTool(wc)', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); }); @@ -1708,40 +1781,60 @@ describe('loadCliConfig interactive', () => { it('should be interactive if isTTY and no prompt', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); it('should be interactive if prompt-interactive is set', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '--prompt-interactive', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); it('should not be interactive if not isTTY and no prompt', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); it('should not be interactive if prompt is set', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--prompt', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); it('should not be interactive if positional prompt words are provided with other flags', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro', 'Hello']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); @@ -1755,8 +1848,12 @@ describe('loadCliConfig interactive', () => { '--yolo', 'Hello world', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); // Verify the question is preserved for one-shot execution expect(argv.prompt).toBe('Hello world'); @@ -1766,8 +1863,12 @@ describe('loadCliConfig interactive', () => { it('should not be interactive if positional prompt words are provided with extensions flag', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '-e', 'none', 'hello']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello'); expect(argv.extensions).toEqual(['none']); @@ -1776,8 +1877,12 @@ describe('loadCliConfig interactive', () => { it('should handle multiple positional words correctly', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', 'hello world how are you']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello world how are you'); expect(argv.prompt).toBe('hello world how are you'); @@ -1797,8 +1902,12 @@ describe('loadCliConfig interactive', () => { 'sort', 'array', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('write a function to sort array'); expect(argv.model).toBe('gemini-2.5-pro'); @@ -1807,8 +1916,12 @@ describe('loadCliConfig interactive', () => { it('should handle empty positional arguments', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); expect(argv.query).toBeUndefined(); }); @@ -1826,8 +1939,12 @@ describe('loadCliConfig interactive', () => { 'are', 'you', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello world how are you'); expect(argv.extensions).toEqual(['none']); @@ -1836,8 +1953,12 @@ describe('loadCliConfig interactive', () => { it('should be interactive if no positional prompt words are provided with flags', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); }); @@ -1865,43 +1986,67 @@ describe('loadCliConfig approval mode', () => { it('should default to DEFAULT approval mode when no flags are set', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set YOLO approval mode when --yolo flag is used', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set YOLO approval mode when -y flag is used', async () => { process.argv = ['node', 'script.js', '-y']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set DEFAULT approval mode when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); }); it('should set YOLO approval mode when --approval-mode=yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -1909,20 +2054,64 @@ describe('loadCliConfig approval mode', () => { // Note: This test documents the intended behavior, but in practice the validation // prevents both flags from being used together process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); // Manually set yolo to true to simulate what would happen if validation didn't prevent it argv.yolo = true; - const config = await loadCliConfig({}, 'test-session', argv); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should fall back to --yolo behavior when --approval-mode is not set', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); + it('should set Plan approval mode when --approval-mode=plan is used and experimental.plan is enabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + plan: true, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); + }); + + it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + plan: false, + }, + }); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + }); + + it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({}); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + }); + // --- Untrusted Folder Scenarios --- describe('when folder is NOT trusted', () => { beforeEach(() => { @@ -1934,29 +2123,45 @@ describe('loadCliConfig approval mode', () => { it('should override --approval-mode=yolo to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --approval-mode=auto_edit to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --yolo flag to DEFAULT', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should remain DEFAULT when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); }); @@ -2032,11 +2237,11 @@ describe('loadCliConfig fileFiltering', () => { it.each(testCases)( 'should pass $property from settings to config when $value', async ({ property, getter, value }) => { - const settings: Settings = { + const settings = createTestMergedSettings({ context: { fileFiltering: { [property]: value }, }, - }; + }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(getter(config)).toBe(value); @@ -2055,16 +2260,20 @@ describe('Output format', () => { it('should default to TEXT', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getOutputFormat()).toBe(OutputFormat.TEXT); }); it('should use the format from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { output: { format: OutputFormat.JSON } }, + createTestMergedSettings({ output: { format: OutputFormat.JSON } }), 'test-session', argv, ); @@ -2073,9 +2282,9 @@ describe('Output format', () => { it('should prioritize the format from argv', async () => { process.argv = ['node', 'script.js', '--output-format', 'json']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { output: { format: OutputFormat.JSON } }, + createTestMergedSettings({ output: { format: OutputFormat.JSON } }), 'test-session', argv, ); @@ -2084,8 +2293,12 @@ describe('Output format', () => { it('should accept stream-json as a valid output format', async () => { process.argv = ['node', 'script.js', '--output-format', 'stream-json']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getOutputFormat()).toBe(OutputFormat.STREAM_JSON); }); @@ -2103,7 +2316,7 @@ describe('Output format', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); expect(debugErrorSpy).toHaveBeenCalledWith( @@ -2145,7 +2358,7 @@ describe('parseArguments with positional prompt', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -2162,7 +2375,7 @@ describe('parseArguments with positional prompt', () => { it('should correctly parse a positional prompt to query field', async () => { process.argv = ['node', 'script.js', 'positional', 'prompt']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.query).toBe('positional prompt'); // Since no explicit prompt flags are set and query doesn't start with @, should map to prompt (one-shot) expect(argv.prompt).toBe('positional prompt'); @@ -2175,13 +2388,13 @@ describe('parseArguments with positional prompt', () => { // This test verifies that the positional 'query' argument is properly configured // with the description: "Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive." process.argv = ['node', 'script.js', 'test', 'query']; - const argv = await yargsInstance.parseArguments({} as Settings); + const argv = await yargsInstance.parseArguments(createTestMergedSettings()); expect(argv.query).toBe('test query'); }); it('should correctly parse a prompt from the --prompt flag', async () => { process.argv = ['node', 'script.js', '--prompt', 'test prompt']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.prompt).toBe('test prompt'); }); }); @@ -2197,8 +2410,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_ENABLED over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', 'true'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { enabled: false } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { enabled: false }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2206,10 +2421,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_TARGET over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'gcp'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('gcp'); }); @@ -2217,10 +2432,10 @@ describe('Telemetry configuration via environment variables', () => { it('should throw when GEMINI_TELEMETRY_TARGET is invalid', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'bogus'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.GCP }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( /Invalid telemetry configuration: .*Invalid telemetry target/i, ); @@ -2231,10 +2446,10 @@ describe('Telemetry configuration via environment variables', () => { vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://default.env.com'); vi.stubEnv('GEMINI_TELEMETRY_OTLP_ENDPOINT', 'http://gemini.env.com'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { otlpEndpoint: 'http://settings.com' }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com'); }); @@ -2242,8 +2457,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_OTLP_PROTOCOL over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_OTLP_PROTOCOL', 'http'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { otlpProtocol: 'grpc' } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { otlpProtocol: 'grpc' }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); @@ -2251,8 +2468,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_LOG_PROMPTS over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { logPrompts: true } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { logPrompts: true }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); @@ -2260,10 +2479,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_OUTFILE over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_OUTFILE', '/gemini/env/telemetry.log'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { outfile: '/settings/telemetry.log' }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log'); }); @@ -2271,8 +2490,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_USE_COLLECTOR over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_USE_COLLECTOR', 'true'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { useCollector: false } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { useCollector: false }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryUseCollector()).toBe(true); }); @@ -2280,8 +2501,8 @@ describe('Telemetry configuration via environment variables', () => { it('should use settings value when GEMINI_TELEMETRY_ENABLED is not set', async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', undefined); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { enabled: true } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { enabled: true } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2289,10 +2510,10 @@ describe('Telemetry configuration via environment variables', () => { it('should use settings value when GEMINI_TELEMETRY_TARGET is not set', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', undefined); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('local'); }); @@ -2300,17 +2521,21 @@ describe('Telemetry configuration via environment variables', () => { it("should treat GEMINI_TELEMETRY_ENABLED='1' as true", async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '1'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(true); }); it("should treat GEMINI_TELEMETRY_ENABLED='0' as false", async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '0'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { telemetry: { enabled: true } }, + createTestMergedSettings({ telemetry: { enabled: true } }), 'test-session', argv, ); @@ -2320,17 +2545,21 @@ describe('Telemetry configuration via environment variables', () => { it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='1' as true", async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', '1'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='false' as false", async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { telemetry: { logPrompts: true } }, + createTestMergedSettings({ telemetry: { logPrompts: true } }), 'test-session', argv, ); @@ -2355,8 +2584,12 @@ describe('PolicyEngine nonInteractive wiring', () => { it('should set nonInteractive to true in one-shot mode', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect( (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) @@ -2367,8 +2600,12 @@ describe('PolicyEngine nonInteractive wiring', () => { it('should set nonInteractive to false in interactive mode', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); expect( (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) @@ -2392,8 +2629,10 @@ describe('Policy Engine Integration in loadCliConfig', () => { it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => { process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool']; - const settings: Settings = { tools: { allowed: ['settings-tool'] } }; - const argv = await parseArguments({} as Settings); + const settings = createTestMergedSettings({ + tools: { allowed: ['settings-tool'] }, + }); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); @@ -2410,8 +2649,10 @@ describe('Policy Engine Integration in loadCliConfig', () => { it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => { process.stdin.isTTY = false; // Non-interactive to trigger default excludes process.argv = ['node', 'script.js', '-p', 'test']; - const settings: Settings = { tools: { exclude: ['settings-exclude'] } }; - const argv = await parseArguments({} as Settings); + const settings = createTestMergedSettings({ + tools: { exclude: ['settings-exclude'] }, + }); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); @@ -2446,20 +2687,20 @@ describe('loadCliConfig disableYoloMode', () => { it('should allow auto_edit mode even if yolo mode is disabled', async () => { process.argv = ['node', 'script.js', '--approval-mode=auto_edit']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { disableYoloMode: true }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT); }); it('should throw if YOLO mode is attempted when disableYoloMode is true', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { disableYoloMode: true }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode since it is disabled by your admin', ); @@ -2485,12 +2726,12 @@ describe('loadCliConfig secureModeEnabled', () => { it('should throw an error if YOLO mode is attempted when secureModeEnabled is true', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ admin: { secureModeEnabled: true, }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode since it is disabled by your admin', @@ -2499,12 +2740,12 @@ describe('loadCliConfig secureModeEnabled', () => { it('should throw an error if approval-mode=yolo is attempted when secureModeEnabled is true', async () => { process.argv = ['node', 'script.js', '--approval-mode=yolo']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ admin: { secureModeEnabled: true, }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode since it is disabled by your admin', @@ -2513,12 +2754,12 @@ describe('loadCliConfig secureModeEnabled', () => { it('should set disableYoloMode to true when secureModeEnabled is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ admin: { secureModeEnabled: true, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.isYoloModeDisabled()).toBe(true); }); @@ -2548,8 +2789,8 @@ describe('loadCliConfig mcpEnabled', () => { it('should enable MCP by default', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { ...mcpSettings }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...mcpSettings }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpEnabled()).toBe(true); expect(config.getMcpServerCommand()).toBe('mcp-server'); @@ -2560,15 +2801,15 @@ describe('loadCliConfig mcpEnabled', () => { it('should disable MCP when mcpEnabled is false', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...mcpSettings, admin: { mcp: { enabled: false, }, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpEnabled()).toBe(false); expect(config.getMcpServerCommand()).toBeUndefined(); @@ -2579,15 +2820,15 @@ describe('loadCliConfig mcpEnabled', () => { it('should enable MCP when mcpEnabled is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...mcpSettings, admin: { mcp: { enabled: true, }, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpEnabled()).toBe(true); expect(config.getMcpServerCommand()).toBe('mcp-server'); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 341226fed2..591d861d7c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -38,8 +38,12 @@ import { type OutputFormat, GEMINI_MODEL_ALIAS_AUTO, } from '@google/gemini-cli-core'; -import type { Settings } from './settings.js'; -import { saveModelChange, loadSettings } from './settings.js'; +import { + type Settings, + type MergedSettings, + saveModelChange, + loadSettings, +} from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { resolvePath } from '../utils/resolvePath.js'; @@ -54,7 +58,6 @@ import { requestConsentNonInteractive } from './extensions/consent.js'; import { promptForSetting } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; import { runExitCleanup } from '../utils/cleanup.js'; -import { getEnableHooks, getEnableHooksUI } from './settingsSchema.js'; export interface CliArgs { query: string | undefined; @@ -82,7 +85,9 @@ export interface CliArgs { recordResponses: string | undefined; } -export async function parseArguments(settings: Settings): Promise { +export async function parseArguments( + settings: MergedSettings, +): Promise { const rawArgv = hideBin(process.argv); const yargsInstance = yargs(rawArgv) .locale('en') @@ -138,9 +143,9 @@ export async function parseArguments(settings: Settings): Promise { .option('approval-mode', { type: 'string', nargs: 1, - choices: ['default', 'auto_edit', 'yolo'], + choices: ['default', 'auto_edit', 'yolo', 'plan'], description: - 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)', + 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode)', }) .option('experimental-acp', { type: 'boolean', @@ -280,16 +285,16 @@ export async function parseArguments(settings: Settings): Promise { return true; }); - if (settings?.experimental?.extensionManagement ?? true) { + if (settings.experimental.extensionManagement) { yargsInstance.command(extensionsCommand); } - if (settings?.experimental?.skills ?? false) { + if (settings.experimental.skills) { yargsInstance.command(skillsCommand); } // Register hooks command if hooks are enabled - if (getEnableHooksUI(settings)) { + if (settings.tools.enableHooks) { yargsInstance.command(hooksCommand); } @@ -392,7 +397,7 @@ export interface LoadCliConfigOptions { } export async function loadCliConfig( - settings: Settings, + settings: MergedSettings, sessionId: string, argv: CliArgs, options: LoadCliConfigOptions = {}, @@ -487,12 +492,20 @@ export async function loadCliConfig( case 'auto_edit': approvalMode = ApprovalMode.AUTO_EDIT; break; + case 'plan': + if (!(settings.experimental?.plan ?? false)) { + throw new Error( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + } + approvalMode = ApprovalMode.PLAN; + break; case 'default': approvalMode = ApprovalMode.DEFAULT; break; default: throw new Error( - `Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`, + `Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, plan, default`, ); } } else { @@ -573,6 +586,11 @@ export async function loadCliConfig( ); switch (approvalMode) { + case ApprovalMode.PLAN: + // In plan non-interactive mode, all tools that require approval are excluded. + // TODO(#16625): Replace this default exclusion logic with specific rules for plan mode. + extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); + break; case ApprovalMode.DEFAULT: // In default non-interactive mode, all tools that require approval are excluded. extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); @@ -590,10 +608,7 @@ export async function loadCliConfig( } } - const excludeTools = mergeExcludeTools( - settings, - extraExcludes.length > 0 ? extraExcludes : undefined, - ); + const excludeTools = mergeExcludeTools(settings, extraExcludes); // Create a settings object that includes CLI overrides for policy generation const effectiveSettings: Settings = { @@ -742,15 +757,17 @@ export async function loadCliConfig( disableLLMCorrection: settings.tools?.disableLLMCorrection, modelConfigServiceConfig: settings.modelConfigs, // TODO: loading of hooks based on workspace trust - enableHooks: getEnableHooks(settings), - enableHooksUI: getEnableHooksUI(settings), + enableHooks: + (settings.tools?.enableHooks ?? true) && + (settings.hooks?.enabled ?? false), + enableHooksUI: settings.tools?.enableHooks ?? true, hooks: settings.hooks || {}, projectHooks: projectHooks || {}, onModelChange: (model: string) => saveModelChange(loadedSettings, model), onReload: async () => { const refreshedSettings = loadSettings(cwd); return { - disabledSkills: refreshedSettings.merged.skills?.disabled, + disabledSkills: refreshedSettings.merged.skills.disabled, agents: refreshedSettings.merged.agents, }; }, @@ -758,12 +775,12 @@ export async function loadCliConfig( } function mergeExcludeTools( - settings: Settings, - extraExcludes?: string[] | undefined, + settings: MergedSettings, + extraExcludes: string[] = [], ): string[] { const allExcludeTools = new Set([ - ...(settings.tools?.exclude || []), - ...(extraExcludes || []), + ...(settings.tools.exclude || []), + ...extraExcludes, ]); - return [...allExcludeTools]; + return Array.from(allExcludeTools); } diff --git a/packages/cli/src/config/extension-manager-agents.test.ts b/packages/cli/src/config/extension-manager-agents.test.ts index 7ae845875f..19ef150d22 100644 --- a/packages/cli/src/config/extension-manager-agents.test.ts +++ b/packages/cli/src/config/extension-manager-agents.test.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; import { debugLogger } from '@google/gemini-cli-core'; -import { type Settings } from './settings.js'; +import { createTestMergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; @@ -52,10 +52,9 @@ describe('ExtensionManager agents loading', () => { fs.mkdirSync(extensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ - settings: { + settings: createTestMergedSettings({ telemetry: { enabled: false }, - trustedFolders: [tempDir], - } as unknown as Settings, + }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, diff --git a/packages/cli/src/config/extension-manager-scope.test.ts b/packages/cli/src/config/extension-manager-scope.test.ts index 6d3e51b4d8..5079075366 100644 --- a/packages/cli/src/config/extension-manager-scope.test.ts +++ b/packages/cli/src/config/extension-manager-scope.test.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; -import type { Settings } from './settings.js'; +import { createTestMergedSettings } from './settings.js'; import { loadAgentsFromDirectory, loadSkillsFromDir, @@ -105,14 +105,10 @@ describe('ExtensionManager Settings Scope', () => { workspaceDir: tempWorkspace, requestConsent: async () => true, requestSetting: async () => '', - settings: { - telemetry: { - enabled: false, - }, - experimental: { - extensionConfig: true, - }, - } as Settings, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), }); const extensions = await extensionManager.loadExtensions(); @@ -147,14 +143,10 @@ describe('ExtensionManager Settings Scope', () => { workspaceDir: tempWorkspace, requestConsent: async () => true, requestSetting: async () => '', - settings: { - telemetry: { - enabled: false, - }, - experimental: { - extensionConfig: true, - }, - } as Settings, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), }); const extensions = await extensionManager.loadExtensions(); @@ -187,14 +179,10 @@ describe('ExtensionManager Settings Scope', () => { workspaceDir: tempWorkspace, requestConsent: async () => true, requestSetting: async () => '', - settings: { - telemetry: { - enabled: false, - }, - experimental: { - extensionConfig: true, - }, - } as Settings, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), }); const extensions = await extensionManager.loadExtensions(); diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts index ecc0dfa3c0..a76d88482d 100644 --- a/packages/cli/src/config/extension-manager-skills.test.ts +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; import { debugLogger, coreEvents } from '@google/gemini-cli-core'; -import { type Settings } from './settings.js'; +import { createTestMergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; @@ -58,10 +58,9 @@ describe('ExtensionManager skills validation', () => { fs.mkdirSync(extensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ - settings: { + settings: createTestMergedSettings({ telemetry: { enabled: false }, - trustedFolders: [tempDir], - } as unknown as Settings, + }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, @@ -134,10 +133,9 @@ describe('ExtensionManager skills validation', () => { // 3. Create a fresh ExtensionManager to force loading from disk const newExtensionManager = new ExtensionManager({ - settings: { + settings: createTestMergedSettings({ telemetry: { enabled: false }, - trustedFolders: [tempDir], - } as unknown as Settings, + }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 75416f1909..45ca5a0d8a 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -9,7 +9,7 @@ import * as path from 'node:path'; import { stat } from 'node:fs/promises'; import chalk from 'chalk'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; -import { type Settings, SettingScope } from './settings.js'; +import { type MergedSettings, SettingScope } from './settings.js'; import { createHash, randomUUID } from 'node:crypto'; import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; import { @@ -68,11 +68,10 @@ import { ExtensionSettingScope, } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; -import { getEnableHooks } from './settingsSchema.js'; interface ExtensionManagerParams { enabledExtensionOverrides?: string[]; - settings: Settings; + settings: MergedSettings; requestConsent: (consent: string) => Promise; requestSetting: ((setting: ExtensionSetting) => Promise) | null; workspaceDir: string; @@ -86,7 +85,7 @@ interface ExtensionManagerParams { */ export class ExtensionManager extends ExtensionLoader { private extensionEnablementManager: ExtensionEnablementManager; - private settings: Settings; + private settings: MergedSettings; private requestConsent: (consent: string) => Promise; private requestSetting: | ((setting: ExtensionSetting) => Promise) @@ -143,7 +142,7 @@ export class ExtensionManager extends ExtensionLoader { if ( (installMetadata.type === 'git' || installMetadata.type === 'github-release') && - this.settings.security?.blockGitExtensions + this.settings.security.blockGitExtensions ) { throw new Error( 'Installing extensions from remote sources is disallowed by your current settings.', @@ -287,10 +286,7 @@ Would you like to attempt to install via "git clone" instead?`, } await fs.promises.mkdir(destinationPath, { recursive: true }); - if ( - this.requestSetting && - (this.settings.experimental?.extensionConfig ?? false) - ) { + if (this.requestSetting && this.settings.experimental.extensionConfig) { if (isUpdate) { await maybePromptForSettings( newExtensionConfig, @@ -308,14 +304,13 @@ Would you like to attempt to install via "git clone" instead?`, } } - const missingSettings = - (this.settings.experimental?.extensionConfig ?? false) - ? await getMissingSettings( - newExtensionConfig, - extensionId, - this.workspaceDir, - ) - : []; + const missingSettings = this.settings.experimental.extensionConfig + ? await getMissingSettings( + newExtensionConfig, + extensionId, + this.workspaceDir, + ) + : []; if (missingSettings.length > 0) { const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings .map((s) => s.name) @@ -478,7 +473,7 @@ Would you like to attempt to install via "git clone" instead?`, throw new Error('Extensions already loaded, only load extensions once.'); } - if (this.settings.admin?.extensions?.enabled === false) { + if (this.settings.admin.extensions.enabled === false) { this.loadedExtensions = []; return this.loadedExtensions; } @@ -511,7 +506,7 @@ Would you like to attempt to install via "git clone" instead?`, if ( (installMetadata?.type === 'git' || installMetadata?.type === 'github-release') && - this.settings.security?.blockGitExtensions + this.settings.security.blockGitExtensions ) { return null; } @@ -535,7 +530,7 @@ Would you like to attempt to install via "git clone" instead?`, let userSettings: Record = {}; let workspaceSettings: Record = {}; - if (this.settings.experimental?.extensionConfig ?? false) { + if (this.settings.experimental.extensionConfig) { userSettings = await getScopedEnvContents( config, extensionId, @@ -553,10 +548,7 @@ Would you like to attempt to install via "git clone" instead?`, config = resolveEnvVarsInObject(config, customEnv); const resolvedSettings: ResolvedExtensionSetting[] = []; - if ( - config.settings && - (this.settings.experimental?.extensionConfig ?? false) - ) { + if (config.settings && this.settings.experimental.extensionConfig) { for (const setting of config.settings) { const value = customEnv[setting.envVar]; let scope: 'user' | 'workspace' | undefined; @@ -600,7 +592,7 @@ Would you like to attempt to install via "git clone" instead?`, } if (config.mcpServers) { - if (this.settings.admin?.mcp?.enabled === false) { + if (this.settings.admin.mcp.enabled === false) { config.mcpServers = undefined; } else { config.mcpServers = Object.fromEntries( @@ -619,7 +611,7 @@ Would you like to attempt to install via "git clone" instead?`, .filter((contextFilePath) => fs.existsSync(contextFilePath)); let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; - if (getEnableHooks(this.settings)) { + if (this.settings.tools.enableHooks && this.settings.hooks.enabled) { hooks = await this.loadExtensionHooks(effectiveExtensionPath, { extensionPath: effectiveExtensionPath, workspacePath: this.workspaceDir, diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index a3230058f7..55f44a6c20 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -26,7 +26,11 @@ import { loadAgentsFromDirectory, loadSkillsFromDir, } from '@google/gemini-cli-core'; -import { loadSettings, SettingScope } from './settings.js'; +import { + loadSettings, + createTestMergedSettings, + SettingScope, +} from './settings.js'; import { isWorkspaceTrusted, resetTrustedFoldersForTesting, @@ -201,7 +205,7 @@ describe('extension tests', () => { }); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); const settings = loadSettings(tempWorkspaceDir).merged; - (settings.experimental ??= {}).extensionConfig = true; + settings.experimental.extensionConfig = true; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, @@ -628,11 +632,9 @@ describe('extension tests', () => { }, }); - const blockGitExtensionsSetting = { - security: { - blockGitExtensions: true, - }, - }; + const blockGitExtensionsSetting = createTestMergedSettings({ + security: { blockGitExtensions: true }, + }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, @@ -652,7 +654,6 @@ describe('extension tests', () => { version: '1.0.0', }); const loadedSettings = loadSettings(tempWorkspaceDir).merged; - (loadedSettings.admin ??= {}).extensions ??= {}; loadedSettings.admin.extensions.enabled = false; extensionManager = new ExtensionManager({ @@ -676,7 +677,6 @@ describe('extension tests', () => { }, }); const loadedSettings = loadSettings(tempWorkspaceDir).merged; - (loadedSettings.admin ??= {}).mcp ??= {}; loadedSettings.admin.mcp.enabled = false; extensionManager = new ExtensionManager({ @@ -701,7 +701,6 @@ describe('extension tests', () => { }, }); const loadedSettings = loadSettings(tempWorkspaceDir).merged; - (loadedSettings.admin ??= {}).mcp ??= {}; loadedSettings.admin.mcp.enabled = true; extensionManager = new ExtensionManager({ @@ -837,7 +836,6 @@ describe('extension tests', () => { ); const settings = loadSettings(tempWorkspaceDir).merged; - if (!settings.hooks) settings.hooks = {}; settings.hooks.enabled = true; extensionManager = new ExtensionManager({ @@ -873,7 +871,6 @@ describe('extension tests', () => { ); const settings = loadSettings(tempWorkspaceDir).merged; - if (!settings.hooks) settings.hooks = {}; settings.hooks.enabled = false; extensionManager = new ExtensionManager({ @@ -1098,11 +1095,9 @@ describe('extension tests', () => { it('should not install a github extension if blockGitExtensions is set', async () => { const gitUrl = 'https://somehost.com/somerepo.git'; - const blockGitExtensionsSetting = { - security: { - blockGitExtensions: true, - }, - }; + const blockGitExtensionsSetting = createTestMergedSettings({ + security: { blockGitExtensions: true }, + }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index a9240a1676..43b19d1228 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -11,7 +11,6 @@ import * as fs from 'node:fs'; import { getMissingSettings } from './extensionSettings.js'; import type { ExtensionConfig } from '../extension.js'; import { ExtensionStorage } from './storage.js'; -import type { Settings } from '../settings.js'; import { KeychainTokenStorage, debugLogger, @@ -21,6 +20,7 @@ import { } from '@google/gemini-cli-core'; import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; import { ExtensionManager } from '../extension-manager.js'; +import { createTestMergedSettings } from '../settings.js'; vi.mock('node:fs', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -247,12 +247,10 @@ describe('extensionUpdates', () => { const manager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, - settings: { - telemetry: { - enabled: false, - }, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, experimental: { extensionConfig: true }, - } as unknown as Settings, + }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: null, // Simulate non-interactive }); diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index b101ae489e..47e4d6c400 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -8,87 +8,79 @@ * Command enum for all available keyboard shortcuts */ export enum Command { - // Basic bindings - RETURN = 'return', - ESCAPE = 'escape', + // Basic Controls + RETURN = 'basic.confirm', + ESCAPE = 'basic.cancel', + QUIT = 'basic.quit', + EXIT = 'basic.exit', - // Cursor movement - HOME = 'home', - END = 'end', + // Cursor Movement + HOME = 'cursor.home', + END = 'cursor.end', + MOVE_UP = 'cursor.up', + MOVE_DOWN = 'cursor.down', + MOVE_LEFT = 'cursor.left', + MOVE_RIGHT = 'cursor.right', + MOVE_WORD_LEFT = 'cursor.wordLeft', + MOVE_WORD_RIGHT = 'cursor.wordRight', - // Text deletion - KILL_LINE_RIGHT = 'killLineRight', - KILL_LINE_LEFT = 'killLineLeft', - CLEAR_INPUT = 'clearInput', - DELETE_WORD_BACKWARD = 'deleteWordBackward', - - // Screen control - CLEAR_SCREEN = 'clearScreen', + // Editing + KILL_LINE_RIGHT = 'edit.deleteRightAll', + KILL_LINE_LEFT = 'edit.deleteLeftAll', + CLEAR_INPUT = 'edit.clear', + DELETE_WORD_BACKWARD = 'edit.deleteWordLeft', + DELETE_WORD_FORWARD = 'edit.deleteWordRight', + DELETE_CHAR_LEFT = 'edit.deleteLeft', + DELETE_CHAR_RIGHT = 'edit.deleteRight', + UNDO = 'edit.undo', + REDO = 'edit.redo', // Scrolling - SCROLL_UP = 'scrollUp', - SCROLL_DOWN = 'scrollDown', - SCROLL_HOME = 'scrollHome', - SCROLL_END = 'scrollEnd', - PAGE_UP = 'pageUp', - PAGE_DOWN = 'pageDown', + SCROLL_UP = 'scroll.up', + SCROLL_DOWN = 'scroll.down', + SCROLL_HOME = 'scroll.home', + SCROLL_END = 'scroll.end', + PAGE_UP = 'scroll.pageUp', + PAGE_DOWN = 'scroll.pageDown', - // History navigation - HISTORY_UP = 'historyUp', - HISTORY_DOWN = 'historyDown', - NAVIGATION_UP = 'navigationUp', - NAVIGATION_DOWN = 'navigationDown', + // History & Search + HISTORY_UP = 'history.previous', + HISTORY_DOWN = 'history.next', + REVERSE_SEARCH = 'history.search.start', + SUBMIT_REVERSE_SEARCH = 'history.search.submit', + ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept', - // Dialog navigation - DIALOG_NAVIGATION_UP = 'dialogNavigationUp', - DIALOG_NAVIGATION_DOWN = 'dialogNavigationDown', + // Navigation + NAVIGATION_UP = 'nav.up', + NAVIGATION_DOWN = 'nav.down', + DIALOG_NAVIGATION_UP = 'nav.dialog.up', + DIALOG_NAVIGATION_DOWN = 'nav.dialog.down', - // Auto-completion - ACCEPT_SUGGESTION = 'acceptSuggestion', - COMPLETION_UP = 'completionUp', - COMPLETION_DOWN = 'completionDown', + // Suggestions & Completions + ACCEPT_SUGGESTION = 'suggest.accept', + COMPLETION_UP = 'suggest.focusPrevious', + COMPLETION_DOWN = 'suggest.focusNext', + EXPAND_SUGGESTION = 'suggest.expand', + COLLAPSE_SUGGESTION = 'suggest.collapse', - // Text input - SUBMIT = 'submit', - NEWLINE = 'newline', + // Text Input + SUBMIT = 'input.submit', + NEWLINE = 'input.newline', + OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor', + PASTE_CLIPBOARD = 'input.paste', - // External tools - OPEN_EXTERNAL_EDITOR = 'openExternalEditor', - PASTE_CLIPBOARD = 'pasteClipboard', - - // App level bindings - SHOW_ERROR_DETAILS = 'showErrorDetails', - SHOW_FULL_TODOS = 'showFullTodos', - SHOW_IDE_CONTEXT_DETAIL = 'showIDEContextDetail', - TOGGLE_MARKDOWN = 'toggleMarkdown', - TOGGLE_COPY_MODE = 'toggleCopyMode', - TOGGLE_YOLO = 'toggleYolo', - TOGGLE_AUTO_EDIT = 'toggleAutoEdit', - UNDO = 'undo', - REDO = 'redo', - MOVE_UP = 'moveUp', - MOVE_DOWN = 'moveDown', - MOVE_LEFT = 'moveLeft', - MOVE_RIGHT = 'moveRight', - MOVE_WORD_LEFT = 'moveWordLeft', - MOVE_WORD_RIGHT = 'moveWordRight', - DELETE_CHAR_LEFT = 'deleteCharLeft', - DELETE_CHAR_RIGHT = 'deleteCharRight', - DELETE_WORD_FORWARD = 'deleteWordForward', - QUIT = 'quit', - EXIT = 'exit', - SHOW_MORE_LINES = 'showMoreLines', - - // Shell commands - REVERSE_SEARCH = 'reverseSearch', - SUBMIT_REVERSE_SEARCH = 'submitReverseSearch', - ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch', - FOCUS_SHELL_INPUT = 'focusShellInput', - UNFOCUS_SHELL_INPUT = 'unfocusShellInput', - - // Suggestion expansion - EXPAND_SUGGESTION = 'expandSuggestion', - COLLAPSE_SUGGESTION = 'collapseSuggestion', + // App Controls + SHOW_ERROR_DETAILS = 'app.showErrorDetails', + SHOW_FULL_TODOS = 'app.showFullTodos', + SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail', + TOGGLE_MARKDOWN = 'app.toggleMarkdown', + TOGGLE_COPY_MODE = 'app.toggleCopyMode', + TOGGLE_YOLO = 'app.toggleYolo', + TOGGLE_AUTO_EDIT = 'app.toggleAutoEdit', + SHOW_MORE_LINES = 'app.showMoreLines', + FOCUS_SHELL_INPUT = 'app.focusShellInput', + UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput', + CLEAR_SCREEN = 'app.clearScreen', } /** @@ -117,24 +109,17 @@ export type KeyBindingConfig = { * Matches the original hard-coded logic exactly */ export const defaultKeyBindings: KeyBindingConfig = { - // Basic bindings + // Basic Controls [Command.RETURN]: [{ key: 'return' }], [Command.ESCAPE]: [{ key: 'escape' }], + [Command.QUIT]: [{ key: 'c', ctrl: true }], + [Command.EXIT]: [{ key: 'd', ctrl: true }], - // Cursor movement + // Cursor Movement [Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }], [Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }], - - // Text deletion - [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], - [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], - [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], - // Added command (meta/alt/option) for mac compatibility - [Command.DELETE_WORD_BACKWARD]: [ - { key: 'backspace', ctrl: true }, - { key: 'backspace', command: true }, - { key: 'w', ctrl: true }, - ], + [Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }], + [Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }], [Command.MOVE_LEFT]: [ { key: 'left', ctrl: false, command: false }, { key: 'b', ctrl: true }, @@ -143,8 +128,6 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'right', ctrl: false, command: false }, { key: 'f', ctrl: true }, ], - [Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }], - [Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }], [Command.MOVE_WORD_LEFT]: [ { key: 'left', ctrl: true }, { key: 'left', command: true }, @@ -155,15 +138,25 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'right', command: true }, { key: 'f', command: true }, ], - [Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }], - [Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }], + + // Editing + [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], + [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], + [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], + // Added command (meta/alt/option) for mac compatibility + [Command.DELETE_WORD_BACKWARD]: [ + { key: 'backspace', ctrl: true }, + { key: 'backspace', command: true }, + { key: 'w', ctrl: true }, + ], [Command.DELETE_WORD_FORWARD]: [ { key: 'delete', ctrl: true }, { key: 'delete', command: true }, ], - - // Screen control - [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], + [Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }], + [Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }], + [Command.UNDO]: [{ key: 'z', ctrl: true, shift: false }], + [Command.REDO]: [{ key: 'z', ctrl: true, shift: true }], // Scrolling [Command.SCROLL_UP]: [{ key: 'up', shift: true }], @@ -173,13 +166,17 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.PAGE_UP]: [{ key: 'pageup' }], [Command.PAGE_DOWN]: [{ key: 'pagedown' }], - // History navigation + // History & Search [Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }], [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }], + [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], + // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste + [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], + [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], + + // Navigation [Command.NAVIGATION_UP]: [{ key: 'up', shift: false }], [Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }], - - // Dialog navigation // Navigation shortcuts appropriate for dialogs where we do not need to accept // text input. [Command.DIALOG_NAVIGATION_UP]: [ @@ -191,7 +188,7 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'j', shift: false }, ], - // Auto-completion + // Suggestions & Completions [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }], // Completion navigation (arrow or Ctrl+P/N) [Command.COMPLETION_UP]: [ @@ -202,8 +199,10 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'down', shift: false }, { key: 'n', ctrl: true, shift: false }, ], + [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], + [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], - // Text input + // Text Input // Must also exclude shift to allow shift+enter for newline [Command.SUBMIT]: [ { @@ -221,15 +220,13 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'return', shift: true }, { key: 'j', ctrl: true }, ], - - // External tools [Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }], [Command.PASTE_CLIPBOARD]: [ { key: 'v', ctrl: true }, { key: 'v', command: true }, ], - // App level bindings + // App Controls [Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }], [Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }], [Command.SHOW_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], @@ -237,22 +234,10 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }], [Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }], [Command.TOGGLE_AUTO_EDIT]: [{ key: 'tab', shift: true }], - [Command.UNDO]: [{ key: 'z', ctrl: true, shift: false }], - [Command.REDO]: [{ key: 'z', ctrl: true, shift: true }], - [Command.QUIT]: [{ key: 'c', ctrl: true }], - [Command.EXIT]: [{ key: 'd', ctrl: true }], [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], - - // Shell commands - [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], - // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste - [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], - [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }], [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }], - // Suggestion expansion - [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], - [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], + [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], }; interface CommandCategory { @@ -266,7 +251,7 @@ interface CommandCategory { export const commandCategories: readonly CommandCategory[] = [ { title: 'Basic Controls', - commands: [Command.RETURN, Command.ESCAPE], + commands: [Command.RETURN, Command.ESCAPE, Command.QUIT, Command.EXIT], }, { title: 'Cursor Movement', @@ -295,10 +280,6 @@ export const commandCategories: readonly CommandCategory[] = [ Command.REDO, ], }, - { - title: 'Screen Control', - commands: [Command.CLEAR_SCREEN], - }, { title: 'Scrolling', commands: [ @@ -341,11 +322,12 @@ export const commandCategories: readonly CommandCategory[] = [ }, { title: 'Text Input', - commands: [Command.SUBMIT, Command.NEWLINE], - }, - { - title: 'External Tools', - commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD], + commands: [ + Command.SUBMIT, + Command.NEWLINE, + Command.OPEN_EXTERNAL_EDITOR, + Command.PASTE_CLIPBOARD, + ], }, { title: 'App Controls', @@ -360,28 +342,33 @@ export const commandCategories: readonly CommandCategory[] = [ Command.SHOW_MORE_LINES, Command.FOCUS_SHELL_INPUT, Command.UNFOCUS_SHELL_INPUT, + Command.CLEAR_SCREEN, ], }, - { - title: 'Session Control', - commands: [Command.QUIT, Command.EXIT], - }, ]; /** * Human-readable descriptions for each command, used in docs/tooling. */ export const commandDescriptions: Readonly> = { + // Basic Controls [Command.RETURN]: 'Confirm the current selection or choice.', [Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.', + [Command.QUIT]: + 'Cancel the current request or quit the CLI when input is empty.', + [Command.EXIT]: 'Exit the CLI when the input buffer is empty.', + + // Cursor Movement [Command.HOME]: 'Move the cursor to the start of the line.', [Command.END]: 'Move the cursor to the end of the line.', - [Command.MOVE_LEFT]: 'Move the cursor one character to the left.', - [Command.MOVE_RIGHT]: 'Move the cursor one character to the right.', [Command.MOVE_UP]: 'Move the cursor up one line.', [Command.MOVE_DOWN]: 'Move the cursor down one line.', + [Command.MOVE_LEFT]: 'Move the cursor one character to the left.', + [Command.MOVE_RIGHT]: 'Move the cursor one character to the right.', [Command.MOVE_WORD_LEFT]: 'Move the cursor one word to the left.', [Command.MOVE_WORD_RIGHT]: 'Move the cursor one word to the right.', + + // Editing [Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.', [Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.', [Command.CLEAR_INPUT]: 'Clear all text in the input field.', @@ -391,45 +378,54 @@ export const commandDescriptions: Readonly> = { [Command.DELETE_CHAR_RIGHT]: 'Delete the character to the right.', [Command.UNDO]: 'Undo the most recent text edit.', [Command.REDO]: 'Redo the most recent undone text edit.', - [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', + + // Scrolling [Command.SCROLL_UP]: 'Scroll content up.', [Command.SCROLL_DOWN]: 'Scroll content down.', [Command.SCROLL_HOME]: 'Scroll to the top.', [Command.SCROLL_END]: 'Scroll to the bottom.', [Command.PAGE_UP]: 'Scroll up by one page.', [Command.PAGE_DOWN]: 'Scroll down by one page.', + + // History & Search [Command.HISTORY_UP]: 'Show the previous entry in history.', [Command.HISTORY_DOWN]: 'Show the next entry in history.', + [Command.REVERSE_SEARCH]: 'Start reverse search through history.', + [Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.', + [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: + 'Accept a suggestion while reverse searching.', + + // Navigation [Command.NAVIGATION_UP]: 'Move selection up in lists.', [Command.NAVIGATION_DOWN]: 'Move selection down in lists.', [Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.', [Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.', + + // Suggestions & Completions [Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.', [Command.COMPLETION_UP]: 'Move to the previous completion option.', [Command.COMPLETION_DOWN]: 'Move to the next completion option.', + [Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.', + [Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.', + + // Text Input [Command.SUBMIT]: 'Submit the current prompt.', [Command.NEWLINE]: 'Insert a newline without submitting.', [Command.OPEN_EXTERNAL_EDITOR]: 'Open the current prompt in an external editor.', [Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.', + + // App Controls [Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.', [Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.', [Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.', [Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.', - [Command.TOGGLE_COPY_MODE]: - 'Toggle copy mode when the terminal is using the alternate buffer.', + [Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.', [Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.', [Command.TOGGLE_AUTO_EDIT]: 'Toggle Auto Edit (auto-accept edits) mode.', - [Command.QUIT]: 'Cancel the current request or quit the CLI.', - [Command.EXIT]: 'Exit the CLI when the input buffer is empty.', [Command.SHOW_MORE_LINES]: - 'Expand a height-constrained response to show additional lines.', - [Command.REVERSE_SEARCH]: 'Start reverse search through history.', - [Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.', - [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: - 'Accept a suggestion while reverse searching.', + 'Expand a height-constrained response to show additional lines when not in alternate buffer mode.', [Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.', [Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.', - [Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.', - [Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.', + [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', }; diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index e9a94836cf..422ca92aad 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -287,6 +287,43 @@ describe('Policy Engine Integration Tests', () => { ).toBe(PolicyDecision.ASK_USER); }); + it('should handle Plan mode correctly', async () => { + const settings: Settings = {}; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.PLAN, + ); + const engine = new PolicyEngine(config); + + // Read and search tools should be allowed + expect( + (await engine.check({ name: 'read_file' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'google_web_search' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'list_directory' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + + // Other tools should be denied via catch all + expect( + (await engine.check({ name: 'replace' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'write_file' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + expect( + (await engine.check({ name: 'run_shell_command' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + + // Unknown tools should be denied via catch-all + expect( + (await engine.check({ name: 'unknown_tool' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + }); + it('should verify priority ordering works correctly in practice', async () => { const settings: Settings = { tools: { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 07cd457785..a7bbd76ca6 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -24,12 +24,24 @@ import { DefaultDark } from '../ui/themes/default.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { type Settings, + type MergedSettings, type MemoryImportFormat, type MergeStrategy, type SettingsSchema, type SettingDefinition, getSettingsSchema, } from './settingsSchema.js'; + +export { + type Settings, + type MergedSettings, + type MemoryImportFormat, + type MergeStrategy, + type SettingsSchema, + type SettingDefinition, + getSettingsSchema, +}; + import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; @@ -59,8 +71,6 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { return current?.mergeStrategy; } -export type { Settings, MemoryImportFormat }; - export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath(); export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH); export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; @@ -201,10 +211,7 @@ export function getDefaultsFromSchema( for (const key in schema) { const definition = schema[key]; if (definition.properties) { - const childDefaults = getDefaultsFromSchema(definition.properties); - if (Object.keys(childDefaults).length > 0) { - defaults[key] = childDefaults; - } + defaults[key] = getDefaultsFromSchema(definition.properties); } else if (definition.default !== undefined) { defaults[key] = definition.default; } @@ -212,13 +219,13 @@ export function getDefaultsFromSchema( return defaults as Settings; } -function mergeSettings( +export function mergeSettings( system: Settings, systemDefaults: Settings, user: Settings, workspace: Settings, isTrusted: boolean, -): Settings { +): MergedSettings { const safeWorkspace = isTrusted ? workspace : ({} as Settings); const schemaDefaults = getDefaultsFromSchema(); @@ -236,7 +243,24 @@ function mergeSettings( user, safeWorkspace, system, - ) as Settings; + ) as MergedSettings; +} + +/** + * Creates a fully populated MergedSettings object for testing purposes. + * It merges the provided overrides with the default settings from the schema. + * + * @param overrides Partial settings to override the defaults. + * @returns A complete MergedSettings object. + */ +export function createTestMergedSettings( + overrides: Partial = {}, +): MergedSettings { + return customDeepMerge( + getMergeStrategyForPath, + getDefaultsFromSchema(), + overrides, + ) as MergedSettings; } export class LoadedSettings { @@ -264,14 +288,14 @@ export class LoadedSettings { readonly isTrusted: boolean; readonly errors: SettingsError[]; - private _merged: Settings; + private _merged: MergedSettings; private _remoteAdminSettings: Partial | undefined; - get merged(): Settings { + get merged(): MergedSettings { return this._merged; } - private computeMergedSettings(): Settings { + private computeMergedSettings(): MergedSettings { const merged = mergeSettings( this.system.settings, this.systemDefaults.settings, @@ -293,7 +317,7 @@ export class LoadedSettings { (path: string[]) => getMergeStrategyForPath(['admin', ...path]), adminDefaults, this._remoteAdminSettings?.admin ?? {}, - ) as Settings['admin']; + ) as MergedSettings['admin']; } return merged; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b7d4cbb296..a9c49ce581 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -14,6 +14,7 @@ import type { BugCommandSettings, TelemetrySettings, AuthType, + AgentOverride, } from '@google/gemini-cli-core'; import { DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, @@ -799,7 +800,7 @@ const SETTINGS_SCHEMA = { label: 'Agent Overrides', category: 'Advanced', requiresRestart: true, - default: {}, + default: {} as Record, description: 'Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.', showInDialog: false, @@ -2092,6 +2093,10 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + enabled: { + type: 'boolean', + description: 'Whether to enable the agent.', + }, disabled: { type: 'boolean', description: 'Whether to disable the agent.', @@ -2262,12 +2267,17 @@ type InferSettings = { : T[K]['default']; }; +type InferMergedSettings = { + -readonly [K in keyof T]-?: T[K] extends { properties: SettingsSchema } + ? InferMergedSettings + : T[K]['type'] extends 'enum' + ? T[K]['options'] extends readonly SettingEnumOption[] + ? T[K]['options'][number]['value'] + : T[K]['default'] + : T[K]['default'] extends boolean + ? boolean + : T[K]['default']; +}; + export type Settings = InferSettings; - -export function getEnableHooksUI(settings: Settings): boolean { - return settings.tools?.enableHooks ?? true; -} - -export function getEnableHooks(settings: Settings): boolean { - return getEnableHooksUI(settings) && (settings.hooks?.enabled ?? false); -} +export type MergedSettings = InferMergedSettings; diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 61a4b00422..57f1c41551 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -127,7 +127,7 @@ describe('initializer', () => { }); it('should handle undefined auth type', async () => { - mockSettings.merged.security!.auth!.selectedType = undefined; + mockSettings.merged.security.auth.selectedType = undefined; const result = await initializeApp( mockConfig as unknown as Config, mockSettings, diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 0ba76a989f..e99efd90f6 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -39,13 +39,13 @@ export async function initializeApp( const authHandle = startupProfiler.start('authenticate'); const authError = await performInitialAuth( config, - settings.merged.security?.auth?.selectedType, + settings.merged.security.auth.selectedType, ); authHandle?.end(); const themeError = validateTheme(settings); const shouldOpenAuthDialog = - settings.merged.security?.auth?.selectedType === undefined || !!authError; + settings.merged.security.auth.selectedType === undefined || !!authError; logCliConfiguration( config, diff --git a/packages/cli/src/core/theme.test.ts b/packages/cli/src/core/theme.test.ts index fb57d2cde3..eb87a9ee10 100644 --- a/packages/cli/src/core/theme.test.ts +++ b/packages/cli/src/core/theme.test.ts @@ -46,7 +46,7 @@ describe('theme', () => { }); it('should return null if theme is undefined', () => { - mockSettings.merged.ui!.theme = undefined; + mockSettings.merged.ui.theme = undefined; const result = validateTheme(mockSettings); expect(result).toBeNull(); expect(themeManager.findThemeByName).not.toHaveBeenCalled(); diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts index ed2805a5ab..f0f58fdbba 100644 --- a/packages/cli/src/core/theme.ts +++ b/packages/cli/src/core/theme.ts @@ -13,7 +13,7 @@ import { type LoadedSettings } from '../config/settings.js'; * @returns An error message if the theme is not found, otherwise null. */ export function validateTheme(settings: LoadedSettings): string | null { - const effectiveTheme = settings.merged.ui?.theme; + const effectiveTheme = settings.merged.ui.theme; if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { return `Theme "${effectiveTheme}" not found.`; } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9619035b0d..896f89e3c8 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -23,8 +23,30 @@ import { import os from 'node:os'; import v8 from 'node:v8'; import { type CliArgs } from './config/config.js'; -import { type LoadedSettings } from './config/settings.js'; +import { + type LoadedSettings, + type Settings, + createTestMergedSettings, +} from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; + +function createMockSettings( + overrides: Record = {}, +): LoadedSettings { + const merged = createTestMergedSettings( + (overrides['merged'] as Partial) || {}, + ); + + return { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + errors: [], + ...overrides, + merged, + } as unknown as LoadedSettings; +} import { type Config, type ResumedSessionData, @@ -108,26 +130,19 @@ class MockProcessExitError extends Error { } // Mock dependencies -vi.mock('./config/settings.js', () => ({ - loadSettings: vi.fn().mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - }), - migrateDeprecatedSettings: vi.fn(), - SettingScope: { - User: 'user', - Workspace: 'workspace', - System: 'system', - SystemDefaults: 'system-defaults', - }, -})); +vi.mock('./config/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: vi.fn().mockImplementation(() => ({ + merged: actual.getDefaultsFromSchema(), + workspace: { settings: {} }, + errors: [], + })), + saveModelChange: vi.fn(), + getDefaultsFromSchema: actual.getDefaultsFromSchema, + }; +}); vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ terminalCapabilityManager: { @@ -443,17 +458,15 @@ describe('gemini.tsx main function kitty protocol', () => { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), }, } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - errors: [], - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({ model: undefined, sandbox: undefined, @@ -505,17 +518,18 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -594,17 +608,18 @@ describe('gemini.tsx main function kitty protocol', () => { promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); const mockConfig = { isInteractive: () => false, @@ -665,17 +680,18 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: { theme: 'non-existent-theme' }, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: { theme: 'non-existent-theme' }, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -753,13 +769,14 @@ describe('gemini.tsx main function kitty protocol', () => { }); const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -839,13 +856,14 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -918,13 +936,14 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -1034,10 +1053,11 @@ describe('gemini.tsx main function exit codes', () => { ); const { loadSettings } = await import('./config/settings.js'); vi.mocked(loadCliConfig).mockResolvedValue({} as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { security: { auth: {} }, ui: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: true, } as unknown as CliArgs); @@ -1066,14 +1086,13 @@ describe('gemini.tsx main function exit codes', () => { vi.mocked(loadCliConfig).mockResolvedValue({ refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')), } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { - security: { auth: { selectedType: 'google', useExternal: false } }, - ui: {}, - }, - workspace: { settings: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + security: { auth: { selectedType: 'google', useExternal: false } }, + }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); vi.mock('./config/auth.js', () => ({ validateAuthMethod: vi.fn().mockReturnValue(null), @@ -1131,11 +1150,11 @@ describe('gemini.tsx main function exit codes', () => { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), }, } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({ resume: 'invalid-session', } as unknown as CliArgs); @@ -1200,11 +1219,11 @@ describe('gemini.tsx main function exit codes', () => { }, getRemoteAdminSettings: () => undefined, } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); Object.defineProperty(process.stdin, 'isTTY', { value: true, // Simulate TTY so it doesn't try to read stdin diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 3f808c20b7..36411feae5 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -213,12 +213,12 @@ export async function startInteractiveUI( @@ -263,8 +263,7 @@ export async function startInteractiveUI( patchConsole: false, alternateBuffer: useAlternateBuffer, incrementalRendering: - settings.merged.ui?.incrementalRendering !== false && - useAlternateBuffer, + settings.merged.ui.incrementalRendering !== false && useAlternateBuffer, }, ); @@ -336,13 +335,13 @@ export async function main() { registerCleanup(consolePatcher.cleanup); dns.setDefaultResultOrder( - validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), + validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder), ); // Set a default auth type if one isn't set or is set to a legacy type if ( - !settings.merged.security?.auth?.selectedType || - settings.merged.security?.auth?.selectedType === AuthType.LEGACY_CLOUD_SHELL + !settings.merged.security.auth.selectedType || + settings.merged.security.auth.selectedType === AuthType.LEGACY_CLOUD_SHELL ) { if ( process.env['CLOUD_SHELL'] === 'true' || @@ -364,8 +363,8 @@ export async function main() { // the sandbox because the sandbox will interfere with the Oauth2 web // redirect. if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal + settings.merged.security.auth.selectedType && + !settings.merged.security.auth.useExternal ) { try { if (partialConfig.isInteractive()) { @@ -381,8 +380,8 @@ export async function main() { ); } else { const authType = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, partialConfig, settings, ); @@ -403,7 +402,7 @@ export async function main() { // hop into sandbox if we are outside and sandboxing is enabled if (!process.env['SANDBOX']) { - const memoryArgs = settings.merged.advanced?.autoConfigureMemory + const memoryArgs = settings.merged.advanced.autoConfigureMemory ? getNodeMemoryArgs(isDebugMode) : []; const sandboxConfig = await loadSandboxConfig(settings.merged, argv); @@ -506,7 +505,7 @@ export async function main() { // Handle --list-sessions flag if (config.getListSessions()) { // Attempt auth for summary generation (gracefully skips if not configured) - const authType = settings.merged.security?.auth?.selectedType; + const authType = settings.merged.security.auth.selectedType; if (authType) { try { await config.refreshAuth(authType); @@ -566,7 +565,7 @@ export async function main() { initAppHandle?.end(); if ( - settings.merged.security?.auth?.selectedType === + settings.merged.security.auth.selectedType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { @@ -678,8 +677,8 @@ export async function main() { ); const authType = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, config, settings, ); @@ -705,14 +704,14 @@ export async function main() { } function setWindowTitle(title: string, settings: LoadedSettings) { - if (!settings.merged.ui?.hideWindowTitle) { + if (!settings.merged.ui.hideWindowTitle) { // Initial state before React loop starts const windowTitle = computeTerminalTitle({ streamingState: StreamingState.Idle, isConfirming: false, folderName: title, - showThoughts: !!settings.merged.ui?.showStatusInTitle, - useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true, + showThoughts: !!settings.merged.ui.showStatusInTitle, + useDynamicTitle: settings.merged.ui.dynamicWindowTitle, }); writeToStdout(`\x1b]0;${windowTitle}\x07`); diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 37a0edcb19..63328b2a21 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -7,6 +7,7 @@ import { vi } from 'vitest'; import type { CommandContext } from '../ui/commands/types.js'; import type { LoadedSettings } from '../config/settings.js'; +import { mergeSettings } from '../config/settings.js'; import type { GitService } from '@google/gemini-cli-core'; import type { SessionStatsState } from '../ui/contexts/SessionContext.js'; @@ -27,6 +28,8 @@ type DeepPartial = T extends object export const createMockCommandContext = ( overrides: DeepPartial = {}, ): CommandContext => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const defaultMocks: CommandContext = { invocation: { raw: '', @@ -35,7 +38,11 @@ export const createMockCommandContext = ( }, services: { config: null, - settings: { merged: {} } as LoadedSettings, + settings: { + merged: defaultMergedSettings, + setValue: vi.fn(), + forScope: vi.fn().mockReturnValue({ settings: {} }), + } as unknown as LoadedSettings, git: undefined as GitService | undefined, logger: { log: vi.fn(), diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 695de98684..404bea4866 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -151,7 +151,7 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - mockLoadedSettings.merged.ui = { useAlternateBuffer: true }; + mockLoadedSettings.merged.ui.useAlternateBuffer = true; const { lastFrame } = renderWithProviders(, quittingUIState); @@ -159,7 +159,7 @@ describe('App', () => { expect(lastFrame()).toContain('Quitting...'); // Reset settings - mockLoadedSettings.merged.ui = { useAlternateBuffer: false }; + mockLoadedSettings.merged.ui.useAlternateBuffer = false; }); it('should render dialog manager when dialogs are visible', () => { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 3dbf61c965..74ad2f35b1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -82,7 +82,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); import ansiEscapes from 'ansi-escapes'; -import type { LoadedSettings } from '../config/settings.js'; +import { type LoadedSettings, mergeSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; @@ -380,14 +380,17 @@ describe('AppContainer State Management', () => { ); // Mock LoadedSettings + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockSettings = { merged: { + ...defaultMergedSettings, hideBanner: false, hideFooter: false, hideTips: false, showMemoryUsage: false, theme: 'default', ui: { + ...defaultMergedSettings.ui, showStatusInTitle: false, hideWindowTitle: false, }, @@ -507,8 +510,10 @@ describe('AppContainer State Management', () => { describe('Settings Integration', () => { it('handles settings with all display options disabled', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const settingsAllHidden = { merged: { + ...defaultMergedSettings, hideBanner: true, hideFooter: true, hideTips: true, @@ -526,8 +531,10 @@ describe('AppContainer State Management', () => { }); it('handles settings with memory usage enabled', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const settingsWithMemory = { merged: { + ...defaultMergedSettings, hideBanner: false, hideFooter: false, hideTips: false, @@ -574,7 +581,7 @@ describe('AppContainer State Management', () => { it('handles undefined settings gracefully', async () => { const undefinedSettings = { - merged: {}, + merged: mergeSettings({}, {}, {}, {}, true), } as LoadedSettings; let unmount: () => void; @@ -991,12 +998,13 @@ describe('AppContainer State Management', () => { it('should update terminal title with Working… when showStatusInTitle is false', () => { // Arrange: Set up mock settings with showStatusInTitle disabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithShowStatusFalse = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: false, hideWindowTitle: false, }, @@ -1073,12 +1081,13 @@ describe('AppContainer State Management', () => { it('should not update terminal title when hideWindowTitle is true', () => { // Arrange: Set up mock settings with hideWindowTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithHideTitleTrue = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: true, }, @@ -1101,12 +1110,13 @@ describe('AppContainer State Management', () => { it('should update terminal title with thought subject when in active state', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1143,12 +1153,13 @@ describe('AppContainer State Management', () => { it('should update terminal title with default text when in Idle state and no thought subject', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1184,12 +1195,13 @@ describe('AppContainer State Management', () => { it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1392,12 +1404,13 @@ describe('AppContainer State Management', () => { it('should pad title to exactly 80 characters', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1435,12 +1448,13 @@ describe('AppContainer State Management', () => { it('should use correct ANSI escape code format', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1802,12 +1816,13 @@ describe('AppContainer State Management', () => { const setupCopyModeTest = async (isAlternateMode = false) => { // Update settings for this test run + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const testSettings = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, useAlternateBuffer: isAlternateMode, }, }, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 10f5a54a1c..46dd1a69c2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -392,8 +392,8 @@ export const AppContainer = (props: AppContainerProps) => { }, []); const getPreferredEditor = useCallback( - () => settings.merged.general?.preferredEditor as EditorType, - [settings.merged.general?.preferredEditor], + () => settings.merged.general.preferredEditor as EditorType, + [settings.merged.general.preferredEditor], ); const buffer = useTextBuffer({ @@ -443,7 +443,7 @@ export const AppContainer = (props: AppContainerProps) => { useEffect(() => { if ( - !(settings.merged.ui?.hideBanner || config.getScreenReader()) && + !(settings.merged.ui.hideBanner || config.getScreenReader()) && bannerVisible && bannerText ) { @@ -603,17 +603,17 @@ Logging in with Google... Restarting Gemini CLI to continue. // Check for enforced auth type mismatch useEffect(() => { if ( - settings.merged.security?.auth?.enforcedType && - settings.merged.security?.auth.selectedType && - settings.merged.security?.auth.enforcedType !== - settings.merged.security?.auth.selectedType + settings.merged.security.auth.enforcedType && + settings.merged.security.auth.selectedType && + settings.merged.security.auth.enforcedType !== + settings.merged.security.auth.selectedType ) { onAuthError( - `Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`, + `Authentication is enforced to be ${settings.merged.security.auth.enforcedType}, but you are currently using ${settings.merged.security.auth.selectedType}.`, ); } else if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal + settings.merged.security.auth.selectedType && + !settings.merged.security.auth.useExternal ) { // We skip validation for Gemini API key here because it might be stored // in the keychain, which we can't check synchronously. @@ -630,9 +630,9 @@ Logging in with Google... Restarting Gemini CLI to continue. } } }, [ - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.enforcedType, - settings.merged.security?.auth?.useExternal, + settings.merged.security.auth.selectedType, + settings.merged.security.auth.enforcedType, + settings.merged.security.auth.useExternal, onAuthError, ]); @@ -951,8 +951,8 @@ Logging in with Google... Restarting Gemini CLI to continue. Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1, ), - pager: settings.merged.tools?.shell?.pager, - showColor: settings.merged.tools?.shell?.showColor, + pager: settings.merged.tools.shell.pager, + showColor: settings.merged.tools.shell.showColor, sanitizationConfig: config.sanitizationConfig, }); @@ -960,13 +960,13 @@ Logging in with Google... Restarting Gemini CLI to continue. // Context file names computation const contextFileNames = useMemo(() => { - const fromSettings = settings.merged.context?.fileName; + const fromSettings = settings.merged.context.fileName; return fromSettings ? Array.isArray(fromSettings) ? fromSettings : [fromSettings] : getAllGeminiMdFilenames(); - }, [settings.merged.context?.fileName]); + }, [settings.merged.context.fileName]); // Initial prompt handling const initialPrompt = useMemo(() => config.getQuestion(), [config]); const initialPromptSubmitted = useRef(false); @@ -1040,7 +1040,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const shouldShowIdePrompt = Boolean( currentIDE && !config.getIdeMode() && - !settings.merged.ide?.hasSeenNudge && + !settings.merged.ide.hasSeenNudge && !idePromptAnswered, ); @@ -1221,7 +1221,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( streamingState, - settings.merged.ui?.customWittyPhrases, + settings.merged.ui.customWittyPhrases, !!activePtyId && !embeddedShellFocused, lastOutputTime, retryStatus, @@ -1237,7 +1237,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } // Debug log keystrokes if enabled - if (settings.merged.general?.debugKeystrokeLogging) { + if (settings.merged.general.debugKeystrokeLogging) { debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } @@ -1337,7 +1337,7 @@ Logging in with Google... Restarting Gemini CLI to continue. cancelOngoingRequest, activePtyId, embeddedShellFocused, - settings.merged.general?.debugKeystrokeLogging, + settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, copyModeEnabled, @@ -1351,7 +1351,7 @@ Logging in with Google... Restarting Gemini CLI to continue. // Update terminal title with Gemini CLI status and thoughts useEffect(() => { // Respect hideWindowTitle settings - if (settings.merged.ui?.hideWindowTitle) return; + if (settings.merged.ui.hideWindowTitle) return; const paddedTitle = computeTerminalTitle({ streamingState, @@ -1361,8 +1361,8 @@ Logging in with Google... Restarting Gemini CLI to continue. !!confirmationRequest || showShellActionRequired, folderName: basename(config.getTargetDir()), - showThoughts: !!settings.merged.ui?.showStatusInTitle, - useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true, + showThoughts: !!settings.merged.ui.showStatusInTitle, + useDynamicTitle: settings.merged.ui.dynamicWindowTitle, }); // Only update the title if it's different from the last value we set @@ -1377,9 +1377,9 @@ Logging in with Google... Restarting Gemini CLI to continue. shellConfirmationRequest, confirmationRequest, showShellActionRequired, - settings.merged.ui?.showStatusInTitle, - settings.merged.ui?.dynamicWindowTitle, - settings.merged.ui?.hideWindowTitle, + settings.merged.ui.showStatusInTitle, + settings.merged.ui.dynamicWindowTitle, + settings.merged.ui.hideWindowTitle, config, stdout, ]); diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 66be01856d..6757979c42 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -152,7 +152,7 @@ describe('AuthDialog', () => { }); it('filters auth types when enforcedType is set', () => { - props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; renderWithProviders(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(1); @@ -160,7 +160,7 @@ describe('AuthDialog', () => { }); it('sets initial index to 0 when enforcedType is set', () => { - props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; renderWithProviders(); const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(initialIndex).toBe(0); @@ -170,7 +170,7 @@ describe('AuthDialog', () => { it.each([ { setup: () => { - props.settings.merged.security!.auth!.selectedType = + props.settings.merged.security.auth.selectedType = AuthType.USE_VERTEX_AI; }, expected: AuthType.USE_VERTEX_AI, @@ -290,7 +290,7 @@ describe('AuthDialog', () => { mockedValidateAuthMethod.mockReturnValue(null); process.env['GEMINI_API_KEY'] = 'test-key-from-env'; // Simulate that the user has already authenticated once - props.settings.merged.security!.auth!.selectedType = + props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; renderWithProviders(); @@ -349,7 +349,7 @@ describe('AuthDialog', () => { { desc: 'calls onAuthError on escape if no auth method is set', setup: () => { - props.settings.merged.security!.auth!.selectedType = undefined; + props.settings.merged.security.auth.selectedType = undefined; }, expectations: (p: typeof props) => { expect(p.onAuthError).toHaveBeenCalledWith( @@ -360,7 +360,7 @@ describe('AuthDialog', () => { { desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set', setup: () => { - props.settings.merged.security!.auth!.selectedType = + props.settings.merged.security.auth.selectedType = AuthType.USE_GEMINI; }, expectations: (p: typeof props) => { @@ -392,7 +392,7 @@ describe('AuthDialog', () => { }); it('renders correctly with enforced auth type', () => { - props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; const { lastFrame } = renderWithProviders(); expect(lastFrame()).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 558927dcf2..0799b38b70 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -78,9 +78,9 @@ export function AuthDialog({ }, ]; - if (settings.merged.security?.auth?.enforcedType) { + if (settings.merged.security.auth.enforcedType) { items = items.filter( - (item) => item.value === settings.merged.security?.auth?.enforcedType, + (item) => item.value === settings.merged.security.auth.enforcedType, ); } @@ -94,7 +94,7 @@ export function AuthDialog({ } let initialAuthIndex = items.findIndex((item) => { - if (settings.merged.security?.auth?.selectedType) { + if (settings.merged.security.auth.selectedType) { return item.value === settings.merged.security.auth.selectedType; } @@ -108,7 +108,7 @@ export function AuthDialog({ return item.value === AuthType.LOGIN_WITH_GOOGLE; }); - if (settings.merged.security?.auth?.enforcedType) { + if (settings.merged.security.auth.enforcedType) { initialAuthIndex = 0; } @@ -171,7 +171,7 @@ export function AuthDialog({ if (authError) { return; } - if (settings.merged.security?.auth?.selectedType === undefined) { + if (settings.merged.security.auth.selectedType === undefined) { // Prevent exiting if no auth method is set onAuthError( 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 004e362d10..7b37e2d421 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -20,11 +20,11 @@ export function validateAuthMethodWithSettings( authType: AuthType, settings: LoadedSettings, ): string | null { - const enforcedType = settings.merged.security?.auth?.enforcedType; + const enforcedType = settings.merged.security.auth.enforcedType; if (enforcedType && enforcedType !== authType) { return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`; } - if (settings.merged.security?.auth?.useExternal) { + if (settings.merged.security.auth.useExternal) { return null; } // If using Gemini API key, we don't validate it here as we might need to prompt for it. @@ -80,7 +80,7 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { return; } - const authType = settings.merged.security?.auth?.selectedType; + const authType = settings.merged.security.auth.selectedType; if (!authType) { if (process.env['GEMINI_API_KEY']) { onAuthError( diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 46589a0c99..3def750895 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -33,7 +33,7 @@ export const aboutCommand: SlashCommand = { const modelVersion = context.services.config?.getModel() || 'Unknown'; const cliVersion = await getVersion(); const selectedAuthType = - context.services.settings.merged.security?.auth?.selectedType || ''; + context.services.settings.merged.security.auth.selectedType || ''; const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || ''; const ideClient = await getIdeClientName(context); diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index f126ddd8ee..e8d2568f60 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -7,7 +7,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { agentsCommand } from './agentsCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import type { Config, AgentOverride } from '@google/gemini-cli-core'; +import type { Config } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import { MessageType } from '../types.js'; import { enableAgent, disableAgent } from '../../utils/agentSettings.js'; @@ -148,12 +148,9 @@ describe('agentsCommand', () => { reload: reloadSpy, }); // Add agent to disabled overrides so validation passes - ( - mockContext.services.settings.merged.agents!.overrides as Record< - string, - AgentOverride - > - )['test-agent'] = { disabled: true }; + mockContext.services.settings.merged.agents.overrides['test-agent'] = { + disabled: true, + }; vi.mocked(enableAgent).mockReturnValue({ status: 'success', @@ -266,12 +263,9 @@ describe('agentsCommand', () => { it('should show info message if agent is already disabled', async () => { mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); - ( - mockContext.services.settings.merged.agents!.overrides as Record< - string, - AgentOverride - > - )['test-agent'] = { disabled: true }; + mockContext.services.settings.merged.agents.overrides['test-agent'] = { + disabled: true, + }; const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index 1c03524332..345d66bb24 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -12,7 +12,6 @@ import type { import { CommandKind } from './types.js'; import { MessageType, type HistoryItemAgentsList } from '../types.js'; import { SettingScope } from '../../config/settings.js'; -import type { AgentOverride } from '@google/gemini-cli-core'; import { disableAgent, enableAgent } from '../../utils/agentSettings.js'; import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; @@ -84,10 +83,7 @@ async function enableAction( } const allAgents = agentRegistry.getAllAgentNames(); - const overrides = (settings.merged.agents?.overrides ?? {}) as Record< - string, - AgentOverride - >; + const overrides = settings.merged.agents.overrides; const disabledAgents = Object.keys(overrides).filter( (name) => overrides[name]?.disabled === true, ); @@ -157,10 +153,7 @@ async function disableAction( } const allAgents = agentRegistry.getAllAgentNames(); - const overrides = (settings.merged.agents?.overrides ?? {}) as Record< - string, - AgentOverride - >; + const overrides = settings.merged.agents.overrides; const disabledAgents = Object.keys(overrides).filter( (name) => overrides[name]?.disabled === true, ); @@ -211,10 +204,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) { const { config, settings } = context.services; if (!config) return []; - const overrides = (settings.merged.agents?.overrides ?? {}) as Record< - string, - AgentOverride - >; + const overrides = settings.merged.agents.overrides; const disabledAgents = Object.entries(overrides) .filter(([_, override]) => override?.disabled === true) .map(([name]) => name); diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index 4f9499c0aa..2d5588dee8 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -271,9 +271,10 @@ describe('hooksCommand', () => { it('should enable a hook and update settings', async () => { // Update the context's settings with disabled hooks - mockContext.services.settings.merged.hooks = { - disabled: ['test-hook', 'other-hook'], - }; + mockContext.services.settings.merged.hooks.disabled = [ + 'test-hook', + 'other-hook', + ]; const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', @@ -401,9 +402,7 @@ describe('hooksCommand', () => { }); it('should disable a hook and update settings', async () => { - mockContext.services.settings.merged.hooks = { - disabled: [], - }; + mockContext.services.settings.merged.hooks.disabled = []; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', @@ -432,9 +431,7 @@ describe('hooksCommand', () => { it('should return info when hook is already disabled', async () => { // Update the context's settings with the hook already disabled - mockContext.services.settings.merged.hooks = { - disabled: ['test-hook'], - }; + mockContext.services.settings.merged.hooks.disabled = ['test-hook']; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', @@ -455,9 +452,7 @@ describe('hooksCommand', () => { }); it('should handle error when disabling hook fails', async () => { - mockContext.services.settings.merged.hooks = { - disabled: [], - }; + mockContext.services.settings.merged.hooks.disabled = []; mockSettings.setValue.mockImplementationOnce(() => { throw new Error('Failed to save settings'); }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 050bf3045e..e8afca5613 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -76,8 +76,7 @@ async function enableAction( // Get current disabled hooks from settings const settings = context.services.settings; - const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]); - + const disabledHooks = settings.merged.hooks.disabled; // Remove from disabled list if present const newDisabledHooks = disabledHooks.filter( (name: string) => name !== hookName, @@ -143,8 +142,7 @@ async function disableAction( // Get current disabled hooks from settings const settings = context.services.settings; - const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]); - + const disabledHooks = settings.merged.hooks.disabled; // Add to disabled list if not already present if (!disabledHooks.includes(hookName)) { const newDisabledHooks = [...disabledHooks, hookName]; diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts index 1e72bce0ae..268d00b9eb 100644 --- a/packages/cli/src/ui/commands/policiesCommand.test.ts +++ b/packages/cli/src/ui/commands/policiesCommand.test.ts @@ -72,6 +72,7 @@ describe('policiesCommand', () => { { decision: PolicyDecision.ALLOW, argsPattern: /safe/, + source: 'test.toml', }, { decision: PolicyDecision.ASK_USER, @@ -101,7 +102,9 @@ describe('policiesCommand', () => { expect(content).toContain( '1. **DENY** tool: `dangerousTool` [Priority: 10]', ); - expect(content).toContain('2. **ALLOW** all tools (args match: `safe`)'); + expect(content).toContain( + '2. **ALLOW** all tools (args match: `safe`) [Source: `test.toml`]', + ); expect(content).toContain('3. **ASK_USER** all tools'); }); }); diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts index cc6136c3d5..f739364c11 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -53,6 +53,9 @@ const listPoliciesCommand: SlashCommand = { if (rule.priority !== undefined) { content += ` [Priority: ${rule.priority}]`; } + if (rule.source) { + content += ` [Source: \`${rule.source}\`]`; + } content += '\n'; }); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index c404c0e9f9..a70a7b20d8 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -26,7 +26,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => { return ( - {!(settings.merged.ui?.hideBanner || config.getScreenReader()) && ( + {!(settings.merged.ui.hideBanner || config.getScreenReader()) && ( <>
{bannerVisible && bannerText && ( @@ -38,7 +38,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => { )} )} - {!(settings.merged.ui?.hideTips || config.getScreenReader()) && ( + {!(settings.merged.ui.hideTips || config.getScreenReader()) && ( )} diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index c39d7c5ece..73e68684a5 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -24,6 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({ })); import { ApprovalMode } from '@google/gemini-cli-core'; import { StreamingState } from '../types.js'; +import { mergeSettings } from '../../config/settings.js'; // Mock child components vi.mock('./LoadingIndicator.js', () => ({ @@ -163,13 +164,20 @@ const createMockConfig = (overrides = {}) => ({ ...overrides, }); -const createMockSettings = (merged = {}) => ({ - merged: { - hideFooter: false, - showMemoryUsage: false, - ...merged, - }, -}); +const createMockSettings = (merged = {}) => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + return { + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + hideFooter: false, + showMemoryUsage: false, + ...merged, + }, + }, + }; +}; /* eslint-disable @typescript-eslint/no-explicit-any */ const renderComposer = ( diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d48cced332..b7db494409 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -82,9 +82,7 @@ export const Composer = () => { { /> )} - {!settings.merged.ui?.hideFooter && !isScreenReaderEnabled &&