diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml index 366564d56e..cc33848941 100644 --- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml @@ -40,6 +40,8 @@ jobs: github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: | const dryRun = process.env.DRY_RUN === 'true'; + const fourteenDaysAgo = new Date(); + fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); @@ -56,48 +58,38 @@ jobs: for (const m of members) maintainerLogins.add(m.login.toLowerCase()); core.info(`Successfully fetched ${members.length} team members from ${team_slug}`); } catch (e) { - core.warning(`Failed to fetch team members from ${team_slug}: ${e.message}`); + // Silently skip if permissions are insufficient; we will rely on author_association + core.debug(`Skipped team fetch for ${team_slug}: ${e.message}`); } } - const isGooglerCache = new Map(); - const isGoogler = async (login) => { - if (isGooglerCache.has(login)) return isGooglerCache.get(login); + const isMaintainer = async (login, assoc) => { + // Reliably identify maintainers using authorAssociation (provided by GitHub) + // and organization membership (if available). + const isTeamMember = maintainerLogins.has(login.toLowerCase()); + const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc); + if (isTeamMember || isRepoMaintainer) return true; + + // Fallback: Check if user belongs to the 'google' or 'googlers' orgs (requires permission) try { - // Check membership in 'googlers' or 'google' orgs const orgs = ['googlers', 'google']; for (const org of orgs) { try { - await github.rest.orgs.checkMembershipForUser({ - org: org, - username: login - }); - core.info(`User ${login} is a member of ${org} organization.`); - isGooglerCache.set(login, true); + await github.rest.orgs.checkMembershipForUser({ org: org, username: login }); return true; } catch (e) { - // 404 just means they aren't a member, which is fine if (e.status !== 404) throw e; } } } catch (e) { - core.warning(`Failed to check org membership for ${login}: ${e.message}`); + // Gracefully ignore failures here } - isGooglerCache.set(login, false); return false; }; - const isMaintainer = async (login, assoc) => { - const isTeamMember = maintainerLogins.has(login.toLowerCase()); - const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc); - if (isTeamMember || isRepoMaintainer) return true; - - return await isGoogler(login); - }; - - // 2. Determine which PRs to check + // 2. Fetch all open PRs let prs = []; if (context.eventName === 'pull_request') { const { data: pr } = await github.rest.pulls.get({ @@ -118,64 +110,77 @@ jobs: for (const pr of prs) { const maintainerPr = await isMaintainer(pr.user.login, pr.author_association); const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]'); + if (maintainerPr || isBot) continue; - // Detection Logic for Linked Issues - // Check 1: Official GitHub "Closing Issue" link (GraphQL) - const linkedIssueQuery = `query($owner:String!, $repo:String!, $number:Int!) { + // Helper: Fetch labels and linked issues via GraphQL + const prDetailsQuery = `query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { pullRequest(number:$number) { - closingIssuesReferences(first: 1) { totalCount } + closingIssuesReferences(first: 10) { + nodes { + number + labels(first: 20) { + nodes { name } + } + } + } } } }`; - let hasClosingLink = false; + let linkedIssues = []; try { - const res = await github.graphql(linkedIssueQuery, { + const res = await github.graphql(prDetailsQuery, { owner: context.repo.owner, repo: context.repo.repo, number: pr.number }); - hasClosingLink = res.repository.pullRequest.closingIssuesReferences.totalCount > 0; - } catch (e) {} - - // Check 2: Regex for mentions (e.g., "Related to #123", "Part of #123", "#123") - // We check for # followed by numbers or direct URLs to issues. - const body = pr.body || ''; - const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i; - const hasMentionLink = mentionRegex.test(body); - - const hasLinkedIssue = hasClosingLink || hasMentionLink; - - // Logic for Closed PRs (Auto-Reopen) - if (pr.state === 'closed' && context.eventName === 'pull_request' && context.payload.action === 'edited') { - if (hasLinkedIssue) { - core.info(`PR #${pr.number} now has a linked issue. Reopening.`); - if (!dryRun) { - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - state: 'open' - }); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: "Thank you for linking an issue! This pull request has been automatically reopened." - }); - } - } - continue; + linkedIssues = res.repository.pullRequest.closingIssuesReferences.nodes; + } catch (e) { + core.warning(`GraphQL fetch failed for PR #${pr.number}: ${e.message}`); } - // Logic for Open PRs (Immediate Closure) - if (pr.state === 'open' && !maintainerPr && !hasLinkedIssue && !isBot) { - core.info(`PR #${pr.number} is missing a linked issue. Closing.`); + // Check for mentions in body as fallback (regex) + const body = pr.body || ''; + const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i; + const matches = body.match(mentionRegex); + if (matches && linkedIssues.length === 0) { + const issueNumber = parseInt(matches[1]); + try { + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + linkedIssues = [{ number: issueNumber, labels: { nodes: issue.labels.map(l => ({ name: l.name })) } }]; + } catch (e) {} + } + + // 3. Enforcement Logic + const prLabels = pr.labels.map(l => l.name.toLowerCase()); + const hasHelpWanted = prLabels.includes('help wanted') || + linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === 'help wanted')); + + const hasMaintainerOnly = prLabels.includes('🔒 maintainer only') || + linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === '🔒 maintainer only')); + + const hasLinkedIssue = linkedIssues.length > 0; + + // Closure Policy: No help-wanted label = Close after 14 days + if (pr.state === 'open' && !hasHelpWanted && !hasMaintainerOnly) { + const prCreatedAt = new Date(pr.created_at); + + // We give a 14-day grace period for non-help-wanted PRs to be manually reviewed/labeled by an EM + if (prCreatedAt > fourteenDaysAgo) { + core.info(`PR #${pr.number} is new and lacks 'help wanted'. Giving 14-day grace period for EM review.`); + continue; + } + + core.info(`PR #${pr.number} is older than 14 days and lacks 'help wanted' association. Closing.`); if (!dryRun) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, - body: "Hi there! Thank you for your contribution to Gemini CLI. \n\nTo improve our contribution process and better track changes, we now require all pull requests to be associated with an existing issue, as announced in our [recent discussion](https://github.com/google-gemini/gemini-cli/discussions/16706) and as detailed in our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md#1-link-to-an-existing-issue).\n\nThis pull request is being closed because it is not currently linked to an issue. **Once you have updated the description of this PR to link an issue (e.g., by adding `Fixes #123` or `Related to #123`), it will be automatically reopened.**\n\n**How to link an issue:**\nAdd a keyword followed by the issue number (e.g., `Fixes #123`) in the description of your pull request. For more details on supported keywords and how linking works, please refer to the [GitHub Documentation on linking pull requests to issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).\n\nThank you for your understanding and for being a part of our community!" + body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we have updated our contribution policy (see [Discussion #17383](https://github.com/google-gemini/gemini-cli/discussions/17383)). \n\n**We only *guarantee* review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.** All other community pull requests are subject to closure after 14 days if they do not align with our current focus areas. For this reason, we strongly recommend that contributors only submit pull requests against issues explicitly labeled as **'help-wanted'**. \n\nThis pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding and for being part of our community!" }); await github.rest.pulls.update({ owner: context.repo.owner, @@ -187,27 +192,22 @@ jobs: continue; } - // Staleness check (Scheduled runs only) - if (pr.state === 'open' && context.eventName !== 'pull_request') { - const labels = pr.labels.map(l => l.name.toLowerCase()); - if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue; + // Also check for linked issue even if it has help wanted (redundant but safe) + if (pr.state === 'open' && !hasLinkedIssue) { + // Already covered by hasHelpWanted check above, but good for future-proofing + continue; + } + // 4. Staleness Check (Scheduled only) + if (pr.state === 'open' && context.eventName !== 'pull_request') { // Skip PRs that were created less than 30 days ago - they cannot be stale yet const prCreatedAt = new Date(pr.created_at); - if (prCreatedAt > thirtyDaysAgo) { - const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24)); - core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`); - continue; - } + if (prCreatedAt > thirtyDaysAgo) continue; - // Initialize lastActivity to PR creation date (not epoch) as a safety baseline. - // This ensures we never incorrectly mark a PR as stale due to failed activity lookups. let lastActivity = new Date(pr.created_at); try { const reviews = await github.paginate(github.rest.pulls.listReviews, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number + owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number }); for (const r of reviews) { if (await isMaintainer(r.user.login, r.author_association)) { @@ -216,9 +216,7 @@ jobs: } } const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number + owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number }); for (const c of comments) { if (await isMaintainer(c.user.login, c.author_association)) { @@ -226,25 +224,23 @@ jobs: if (d > lastActivity) lastActivity = d; } } - } catch (e) { - core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`); - } - - // For maintainer PRs, the PR creation itself counts as maintainer activity. - // (Now redundant since we initialize to pr.created_at, but kept for clarity) - if (maintainerPr) { - const d = new Date(pr.created_at); - if (d > lastActivity) lastActivity = d; - } + } catch (e) {} if (lastActivity < thirtyDaysAgo) { - core.info(`PR #${pr.number} is stale.`); + const labels = pr.labels.map(l => l.name.toLowerCase()); + const isProtected = labels.includes('help wanted') || labels.includes('🔒 maintainer only'); + if (isProtected) { + core.info(`PR #${pr.number} is stale but has a protected label. Skipping closure.`); + continue; + } + + core.info(`PR #${pr.number} is stale (no maintainer activity for 30+ days). Closing.`); if (!dryRun) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, - body: "Hi there! Thank you for your contribution to Gemini CLI. We really appreciate the time and effort you've put into this pull request.\n\nTo keep our backlog manageable and ensure we're focusing on current priorities, we are closing pull requests that haven't seen maintainer activity for 30 days. Currently, the team is prioritizing work associated with **🔒 maintainer only** or **help wanted** issues.\n\nIf you believe this change is still critical, please feel free to comment with updated details. Otherwise, we encourage contributors to focus on open issues labeled as **help wanted**. Thank you for your understanding!" + body: "Hi there! Thank you for your contribution. To keep our backlog manageable, we are closing pull requests that haven't seen maintainer activity for 30 days. If you're still working on this, please let us know!" }); await github.rest.pulls.update({ owner: context.repo.owner, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c71fbe2e22..c6c619219c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -352,21 +352,6 @@ npm run lint - **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. -### Project structure - -- `packages/`: Contains the individual sub-packages of the project. - - `a2a-server`: A2A server implementation for the Gemini CLI. (Experimental) - - `cli/`: The command-line interface. - - `core/`: The core backend logic for the Gemini CLI. - - `test-utils` Utilities for creating and cleaning temporary file systems for - testing. - - `vscode-ide-companion/`: The Gemini CLI Companion extension pairs with - Gemini CLI. -- `docs/`: Contains all project documentation. -- `scripts/`: Utility scripts for building, testing, and development tasks. - -For more detailed architecture, see `docs/architecture.md`. - ### Debugging #### VS Code diff --git a/GEMINI.md b/GEMINI.md index f7017eab40..c08e486b22 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -22,9 +22,10 @@ powerful tool for developers. rendering. - `packages/core`: Backend logic, Gemini API orchestration, prompt construction, and tool execution. - - `packages/core/src/tools/`: Built-in tools for file system, shell, and web - operations. - `packages/a2a-server`: Experimental Agent-to-Agent server. + - `packages/sdk`: Programmatic SDK for embedding Gemini CLI capabilities. + - `packages/devtools`: Integrated developer tools (Network/Console inspector). + - `packages/test-utils`: Shared test utilities and test rig. - `packages/vscode-ide-companion`: VS Code extension pairing with the CLI. ## Building and Running @@ -58,10 +59,6 @@ powerful tool for developers. ## Development Conventions -- **Legacy Snippets:** `packages/core/src/prompts/snippets.legacy.ts` is a - snapshot of an older system prompt. Avoid changing the prompting verbiage to - preserve its historical behavior; however, structural changes to ensure - compilation or simplify the code are permitted. - **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires signing the Google CLA. - **Pull Requests:** Keep PRs small, focused, and linked to an existing issue. @@ -69,8 +66,6 @@ powerful tool for developers. `gh` CLI. - **Commit Messages:** Follow the [Conventional Commits](https://www.conventionalcommits.org/) standard. -- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink) - and `packages/core` (Backend logic). - **Imports:** Use specific imports and avoid restricted relative imports between packages (enforced by ESLint). - **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`), diff --git a/README.md b/README.md index 93485498ed..03a7be1296 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,6 @@ gemini - [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in automated workflows. -- [**Architecture Overview**](./docs/architecture.md) - How Gemini CLI works. - [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion. - [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution environments. diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 4761802403..d79bd910d1 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,17 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.34.0 - 2026-03-17 + +- **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help + you break down complex tasks and execute them systematically + ([#21713](https://github.com/google-gemini/gemini-cli/pull/21713) by @jerop). +- **Sandboxing Enhancements:** We've added native gVisor (runsc) and + experimental LXC container sandboxing support for safer execution environments + ([#21062](https://github.com/google-gemini/gemini-cli/pull/21062) by + @Zheyuan-Lin, [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) + by @h30s). + ## Announcements: v0.33.0 - 2026-03-11 - **Agent Architecture Enhancements:** Introduced HTTP authentication for A2A @@ -125,10 +136,6 @@ on GitHub. ## Announcements: v0.28.0 - 2026-02-10 -- **Slash Command:** We've added a new `/prompt-suggest` slash command to help - you generate prompt suggestions - ([#17264](https://github.com/google-gemini/gemini-cli/pull/17264) by - @NTaylorMullen). - **IDE Support:** Gemini CLI now supports the Positron IDE ([#15047](https://github.com/google-gemini/gemini-cli/pull/15047) by @kapsner). @@ -168,8 +175,8 @@ on GitHub. ([#16638](https://github.com/google-gemini/gemini-cli/pull/16638) by @joshualitt). - **UI/UX Improvements:** You can now "Rewind" through your conversation history - ([#15717](https://github.com/google-gemini/gemini-cli/pull/15717) by @Adib234) - and use a new `/introspect` command for debugging. + ([#15717](https://github.com/google-gemini/gemini-cli/pull/15717) by + @Adib234). - **Core and Scheduler Refactoring:** The core scheduler has been significantly refactored to improve performance and reliability ([#16895](https://github.com/google-gemini/gemini-cli/pull/16895) by diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 5bac5b95e1..e49ef1c652 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.33.1 +# Latest stable release: v0.34.0 -Released: March 12, 2026 +Released: March 17, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,224 +11,474 @@ npm install -g @google/gemini-cli ## Highlights -- **Agent Architecture Enhancements:** Introduced HTTP authentication support - for A2A remote agents, authenticated A2A agent card discovery, and directly - indicated auth-required states. -- **Plan Mode Updates:** Expanded Plan Mode capabilities with built-in research - subagents, annotation support for feedback during iteration, and a new `copy` - subcommand. -- **CLI UX Improvements:** Redesigned the header to be compact with an ASCII - icon, inverted the context window display to show usage, and allowed sub-agent - confirmation requests in the UI while preventing background flicker. -- **ACP & MCP Integrations:** Implemented slash command handling in ACP for - `/memory`, `/init`, `/extensions`, and `/restore`, added an MCPOAuthProvider, - and introduced a `set models` interface for ACP. -- **Admin & Core Stability:** Enabled a 30-day default retention for chat - history, added tool name validation in TOML policy files, and improved tool - parameter extraction. +- **Plan Mode Enabled by Default**: The comprehensive planning capability is now + enabled by default, allowing for better structured task management and + execution. +- **Enhanced Sandboxing Capabilities**: Added support for native gVisor (runsc) + sandboxing as well as experimental LXC container sandboxing to provide more + robust and isolated execution environments. +- **Improved Loop Detection & Recovery**: Implemented iterative loop detection + and model feedback mechanisms to prevent the CLI from getting stuck in + repetitive actions. +- **Customizable UI Elements**: You can now configure a custom footer using the + new `/footer` command, and enjoy standardized semantic focus colors for better + history visibility. +- **Extensive Subagent Updates**: Refinements across the tracker visualization + tools, background process logging, and broader fallback support for models in + tool execution scenarios. ## What's Changed -- fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version - v0.33.0 and create version 0.33.1 by @gemini-cli-robot in - [#22206](https://github.com/google-gemini/gemini-cli/pull/22206) -- Docs: Update model docs to remove Preview Features. by @jkcinouye in - [#20084](https://github.com/google-gemini/gemini-cli/pull/20084) -- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in - [#20153](https://github.com/google-gemini/gemini-cli/pull/20153) -- docs: add Windows PowerShell equivalents for environments and scripting by - @scidomino in [#20333](https://github.com/google-gemini/gemini-cli/pull/20333) -- fix(core): parse raw ASCII buffer strings in Gaxios errors by @sehoon38 in - [#20626](https://github.com/google-gemini/gemini-cli/pull/20626) -- chore(release): bump version to 0.33.0-nightly.20260227.ba149afa0 by @galz10 - in [#20637](https://github.com/google-gemini/gemini-cli/pull/20637) -- fix(github): use robot PAT for automated PRs to pass CLA check by @galz10 in - [#20641](https://github.com/google-gemini/gemini-cli/pull/20641) -- chore/release: bump version to 0.33.0-nightly.20260228.1ca5c05d0 by +- feat(cli): add chat resume footer on session quit by @lordshashank in + [#20667](https://github.com/google-gemini/gemini-cli/pull/20667) +- Support bold and other styles in svg snapshots by @jacob314 in + [#20937](https://github.com/google-gemini/gemini-cli/pull/20937) +- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in + [#21028](https://github.com/google-gemini/gemini-cli/pull/21028) +- Cleanup old branches. by @jacob314 in + [#19354](https://github.com/google-gemini/gemini-cli/pull/19354) +- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by @gemini-cli-robot in - [#20644](https://github.com/google-gemini/gemini-cli/pull/20644) -- Changelog for v0.31.0 by @gemini-cli-robot in - [#20634](https://github.com/google-gemini/gemini-cli/pull/20634) -- fix: use full paths for ACP diff payloads by @JagjeevanAK in - [#19539](https://github.com/google-gemini/gemini-cli/pull/19539) -- Changelog for v0.32.0-preview.0 by @gemini-cli-robot in - [#20627](https://github.com/google-gemini/gemini-cli/pull/20627) -- fix: acp/zed race condition between MCP initialisation and prompt by - @kartikangiras in - [#20205](https://github.com/google-gemini/gemini-cli/pull/20205) -- fix(cli): reset themeManager between tests to ensure isolation by - @NTaylorMullen in - [#20598](https://github.com/google-gemini/gemini-cli/pull/20598) -- refactor(core): Extract tool parameter names as constants by @SandyTao520 in - [#20460](https://github.com/google-gemini/gemini-cli/pull/20460) -- fix(cli): resolve autoThemeSwitching when background hasn't changed but theme - mismatches by @sehoon38 in - [#20706](https://github.com/google-gemini/gemini-cli/pull/20706) -- feat(skills): add github-issue-creator skill by @sehoon38 in - [#20709](https://github.com/google-gemini/gemini-cli/pull/20709) -- fix(cli): allow sub-agent confirmation requests in UI while preventing - background flicker by @abhipatel12 in - [#20722](https://github.com/google-gemini/gemini-cli/pull/20722) -- Merge User and Agent Card Descriptions #20849 by @adamfweidman in - [#20850](https://github.com/google-gemini/gemini-cli/pull/20850) -- fix(core): reduce LLM-based loop detection false positives by @SandyTao520 in - [#20701](https://github.com/google-gemini/gemini-cli/pull/20701) -- fix(plan): deflake plan mode integration tests by @Adib234 in - [#20477](https://github.com/google-gemini/gemini-cli/pull/20477) -- Add /unassign support by @scidomino in - [#20864](https://github.com/google-gemini/gemini-cli/pull/20864) -- feat(core): implement HTTP authentication support for A2A remote agents by - @SandyTao520 in - [#20510](https://github.com/google-gemini/gemini-cli/pull/20510) -- feat(core): centralize read_file limits and update gemini-3 description by + [#21034](https://github.com/google-gemini/gemini-cli/pull/21034) +- feat(ui): standardize semantic focus colors and enhance history visibility by + @keithguerin in + [#20745](https://github.com/google-gemini/gemini-cli/pull/20745) +- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in + [#20928](https://github.com/google-gemini/gemini-cli/pull/20928) +- Add extra safety checks for proto pollution by @jacob314 in + [#20396](https://github.com/google-gemini/gemini-cli/pull/20396) +- feat(core): Add tracker CRUD tools & visualization by @anj-s in + [#19489](https://github.com/google-gemini/gemini-cli/pull/19489) +- Revert "fix(ui): persist expansion in AskUser dialog when navigating options" + by @jacob314 in + [#21042](https://github.com/google-gemini/gemini-cli/pull/21042) +- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in + [#21030](https://github.com/google-gemini/gemini-cli/pull/21030) +- fix: model persistence for all scenarios by @sripasg in + [#21051](https://github.com/google-gemini/gemini-cli/pull/21051) +- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by + @gemini-cli-robot in + [#21054](https://github.com/google-gemini/gemini-cli/pull/21054) +- Consistently guard restarts against concurrent auto updates by @scidomino in + [#21016](https://github.com/google-gemini/gemini-cli/pull/21016) +- Defensive coding to reduce the risk of Maximum update depth errors by + @jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940) +- fix(cli): Polish shell autocomplete rendering to be a little more shell native + feeling. by @jacob314 in + [#20931](https://github.com/google-gemini/gemini-cli/pull/20931) +- Docs: Update plan mode docs by @jkcinouye in + [#19682](https://github.com/google-gemini/gemini-cli/pull/19682) +- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in + [#21050](https://github.com/google-gemini/gemini-cli/pull/21050) +- fix(cli): register extension lifecycle events in DebugProfiler by + @fayerman-source in + [#20101](https://github.com/google-gemini/gemini-cli/pull/20101) +- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in + [#19907](https://github.com/google-gemini/gemini-cli/pull/19907) +- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in + [#19821](https://github.com/google-gemini/gemini-cli/pull/19821) +- Changelog for v0.32.0 by @gemini-cli-robot in + [#21033](https://github.com/google-gemini/gemini-cli/pull/21033) +- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in + [#21058](https://github.com/google-gemini/gemini-cli/pull/21058) +- feat(core): improve @scripts/copy_files.js autocomplete to prioritize + filenames by @sehoon38 in + [#21064](https://github.com/google-gemini/gemini-cli/pull/21064) +- feat(sandbox): add experimental LXC container sandbox support by @h30s in + [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) +- feat(evals): add overall pass rate row to eval nightly summary table by + @gundermanc in + [#20905](https://github.com/google-gemini/gemini-cli/pull/20905) +- feat(telemetry): include language in telemetry and fix accepted lines + computation by @gundermanc in + [#21126](https://github.com/google-gemini/gemini-cli/pull/21126) +- Changelog for v0.32.1 by @gemini-cli-robot in + [#21055](https://github.com/google-gemini/gemini-cli/pull/21055) +- feat(core): add robustness tests, logging, and metrics for CodeAssistServer + SSE parsing by @yunaseoul in + [#21013](https://github.com/google-gemini/gemini-cli/pull/21013) +- feat: add issue assignee workflow by @kartikangiras in + [#21003](https://github.com/google-gemini/gemini-cli/pull/21003) +- fix: improve error message when OAuth succeeds but project ID is required by + @Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070) +- feat(loop-reduction): implement iterative loop detection and model feedback by @aishaneeshah in - [#20619](https://github.com/google-gemini/gemini-cli/pull/20619) -- Do not block CI on evals by @gundermanc in - [#20870](https://github.com/google-gemini/gemini-cli/pull/20870) -- document node limitation for shift+tab by @scidomino in - [#20877](https://github.com/google-gemini/gemini-cli/pull/20877) -- Add install as an option when extension is selected. by @DavidAPierce in - [#20358](https://github.com/google-gemini/gemini-cli/pull/20358) -- Update CODEOWNERS for README.md reviewers by @g-samroberts in - [#20860](https://github.com/google-gemini/gemini-cli/pull/20860) -- feat(core): truncate large MCP tool output by @SandyTao520 in - [#19365](https://github.com/google-gemini/gemini-cli/pull/19365) -- Subagent activity UX. by @gundermanc in - [#17570](https://github.com/google-gemini/gemini-cli/pull/17570) -- style(cli) : Dialog pattern for /hooks Command by @AbdulTawabJuly in - [#17930](https://github.com/google-gemini/gemini-cli/pull/17930) -- feat: redesign header to be compact with ASCII icon by @keithguerin in - [#18713](https://github.com/google-gemini/gemini-cli/pull/18713) -- fix(core): ensure subagents use qualified MCP tool names by @abhipatel12 in - [#20801](https://github.com/google-gemini/gemini-cli/pull/20801) -- feat(core): support authenticated A2A agent card discovery by @SandyTao520 in - [#20622](https://github.com/google-gemini/gemini-cli/pull/20622) -- refactor(cli): fully remove React anti patterns, improve type safety and fix - UX oversights in SettingsDialog.tsx by @psinha40898 in - [#18963](https://github.com/google-gemini/gemini-cli/pull/18963) -- Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider by - @Nayana-Parameswarappa in - [#20121](https://github.com/google-gemini/gemini-cli/pull/20121) -- feat(core): add tool name validation in TOML policy files by @allenhutchison - in [#19281](https://github.com/google-gemini/gemini-cli/pull/19281) -- docs: fix broken markdown links in main README.md by @Hamdanbinhashim in - [#20300](https://github.com/google-gemini/gemini-cli/pull/20300) -- refactor(core): replace manual syncPlanModeTools with declarative policy rules - by @jerop in [#20596](https://github.com/google-gemini/gemini-cli/pull/20596) -- fix(core): increase default headers timeout to 5 minutes by @gundermanc in - [#20890](https://github.com/google-gemini/gemini-cli/pull/20890) -- feat(admin): enable 30 day default retention for chat history & remove warning + [#20763](https://github.com/google-gemini/gemini-cli/pull/20763) +- chore(github): require prompt approvers for agent prompt files by @gundermanc + in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896) +- Docs: Create tools reference by @jkcinouye in + [#19470](https://github.com/google-gemini/gemini-cli/pull/19470) +- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions + by @spencer426 in + [#21045](https://github.com/google-gemini/gemini-cli/pull/21045) +- chore(cli): enable deprecated settings removal by default by @yashodipmore in + [#20682](https://github.com/google-gemini/gemini-cli/pull/20682) +- feat(core): Disable fast ack helper for hints. by @joshualitt in + [#21011](https://github.com/google-gemini/gemini-cli/pull/21011) +- fix(ui): suppress redundant failure note when tool error note is shown by + @NTaylorMullen in + [#21078](https://github.com/google-gemini/gemini-cli/pull/21078) +- docs: document planning workflows with Conductor example by @jerop in + [#21166](https://github.com/google-gemini/gemini-cli/pull/21166) +- feat(release): ship esbuild bundle in npm package by @genneth in + [#19171](https://github.com/google-gemini/gemini-cli/pull/19171) +- fix(extensions): preserve symlinks in extension source path while enforcing + folder trust by @galz10 in + [#20867](https://github.com/google-gemini/gemini-cli/pull/20867) +- fix(cli): defer tool exclusions to policy engine in non-interactive mode by + @EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639) +- fix(ui): removed double padding on rendered content by @devr0306 in + [#21029](https://github.com/google-gemini/gemini-cli/pull/21029) +- fix(core): truncate excessively long lines in grep search output by + @gundermanc in + [#21147](https://github.com/google-gemini/gemini-cli/pull/21147) +- feat: add custom footer configuration via `/footer` by @jackwotherspoon in + [#19001](https://github.com/google-gemini/gemini-cli/pull/19001) +- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in + [#19608](https://github.com/google-gemini/gemini-cli/pull/19608) +- refactor(cli): categorize built-in themes into dark/ and light/ directories by + @JayadityaGit in + [#18634](https://github.com/google-gemini/gemini-cli/pull/18634) +- fix(core): explicitly allow codebase_investigator and cli_help in read-only + mode by @Adib234 in + [#21157](https://github.com/google-gemini/gemini-cli/pull/21157) +- test: add browser agent integration tests by @kunal-10-cloud in + [#21151](https://github.com/google-gemini/gemini-cli/pull/21151) +- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in + [#21136](https://github.com/google-gemini/gemini-cli/pull/21136) +- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by + @SandyTao520 in + [#20895](https://github.com/google-gemini/gemini-cli/pull/20895) +- fix(ui): add partial output to cancelled shell UI by @devr0306 in + [#21178](https://github.com/google-gemini/gemini-cli/pull/21178) +- fix(cli): replace hardcoded keybinding strings with dynamic formatters by + @scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159) +- DOCS: Update quota and pricing page by @g-samroberts in + [#21194](https://github.com/google-gemini/gemini-cli/pull/21194) +- feat(telemetry): implement Clearcut logging for startup statistics by + @yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172) +- feat(triage): add area/documentation to issue triage by @g-samroberts in + [#21222](https://github.com/google-gemini/gemini-cli/pull/21222) +- Fix so shell calls are formatted by @jacob314 in + [#21237](https://github.com/google-gemini/gemini-cli/pull/21237) +- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in + [#21062](https://github.com/google-gemini/gemini-cli/pull/21062) +- docs: use absolute paths for internal links in plan-mode.md by @jerop in + [#21299](https://github.com/google-gemini/gemini-cli/pull/21299) +- fix(core): prevent unhandled AbortError crash during stream loop detection by + @7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123) +- fix:reorder env var redaction checks to scan values first by @kartikangiras in + [#21059](https://github.com/google-gemini/gemini-cli/pull/21059) +- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences by @skeshive in - [#20853](https://github.com/google-gemini/gemini-cli/pull/20853) -- feat(plan): support annotating plans with feedback for iteration by @Adib234 - in [#20876](https://github.com/google-gemini/gemini-cli/pull/20876) -- Add some dos and don'ts to behavioral evals README. by @gundermanc in - [#20629](https://github.com/google-gemini/gemini-cli/pull/20629) -- fix(core): skip telemetry logging for AbortError exceptions by @yunaseoul in - [#19477](https://github.com/google-gemini/gemini-cli/pull/19477) -- fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2 - models by @SandyTao520 in - [#20897](https://github.com/google-gemini/gemini-cli/pull/20897) -- ci(evals): only run evals in CI if prompts or tools changed by @gundermanc in - [#20898](https://github.com/google-gemini/gemini-cli/pull/20898) -- Build binary by @aswinashok44 in - [#18933](https://github.com/google-gemini/gemini-cli/pull/18933) -- Code review fixes as a pr by @jacob314 in - [#20612](https://github.com/google-gemini/gemini-cli/pull/20612) -- fix(ci): handle empty APP_ID in stale PR closer by @bdmorgan in - [#20919](https://github.com/google-gemini/gemini-cli/pull/20919) -- feat(cli): invert context window display to show usage by @keithguerin in - [#20071](https://github.com/google-gemini/gemini-cli/pull/20071) -- fix(plan): clean up session directories and plans on deletion by @jerop in - [#20914](https://github.com/google-gemini/gemini-cli/pull/20914) -- fix(core): enforce optionality for API response fields in code_assist by - @sehoon38 in [#20714](https://github.com/google-gemini/gemini-cli/pull/20714) -- feat(extensions): add support for plan directory in extension manifest by - @mahimashanware in - [#20354](https://github.com/google-gemini/gemini-cli/pull/20354) -- feat(plan): enable built-in research subagents in plan mode by @Adib234 in - [#20972](https://github.com/google-gemini/gemini-cli/pull/20972) -- feat(agents): directly indicate auth required state by @adamfweidman in - [#20986](https://github.com/google-gemini/gemini-cli/pull/20986) -- fix(cli): wait for background auto-update before relaunching by @scidomino in - [#20904](https://github.com/google-gemini/gemini-cli/pull/20904) -- fix: pre-load @scripts/copy_files.js references from external editor prompts - by @kartikangiras in - [#20963](https://github.com/google-gemini/gemini-cli/pull/20963) -- feat(evals): add behavioral evals for ask_user tool by @Adib234 in - [#20620](https://github.com/google-gemini/gemini-cli/pull/20620) -- refactor common settings logic for skills,agents by @ishaanxgupta in - [#17490](https://github.com/google-gemini/gemini-cli/pull/17490) -- Update docs-writer skill with new resource by @g-samroberts in - [#20917](https://github.com/google-gemini/gemini-cli/pull/20917) -- fix(cli): pin clipboardy to ~5.2.x by @scidomino in - [#21009](https://github.com/google-gemini/gemini-cli/pull/21009) -- feat: Implement slash command handling in ACP for - `/memory`,`/init`,`/extensions` and `/restore` by @sripasg in - [#20528](https://github.com/google-gemini/gemini-cli/pull/20528) -- Docs/add hooks reference by @AadithyaAle in - [#20961](https://github.com/google-gemini/gemini-cli/pull/20961) -- feat(plan): add copy subcommand to plan (#20491) by @ruomengz in - [#20988](https://github.com/google-gemini/gemini-cli/pull/20988) -- fix(core): sanitize and length-check MCP tool qualified names by @abhipatel12 - in [#20987](https://github.com/google-gemini/gemini-cli/pull/20987) -- Format the quota/limit style guide. by @g-samroberts in - [#21017](https://github.com/google-gemini/gemini-cli/pull/21017) -- fix(core): send shell output to model on cancel by @devr0306 in - [#20501](https://github.com/google-gemini/gemini-cli/pull/20501) -- remove hardcoded tiername when missing tier by @sehoon38 in - [#21022](https://github.com/google-gemini/gemini-cli/pull/21022) -- feat(acp): add set models interface by @skeshive in - [#20991](https://github.com/google-gemini/gemini-cli/pull/20991) -- fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch - version v0.33.0-preview.0 and create version 0.33.0-preview.1 by + [#21171](https://github.com/google-gemini/gemini-cli/pull/21171) +- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38 + in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283) +- test(core): improve testing for API request/response parsing by @sehoon38 in + [#21227](https://github.com/google-gemini/gemini-cli/pull/21227) +- docs(links): update docs-writer skill and fix broken link by @g-samroberts in + [#21314](https://github.com/google-gemini/gemini-cli/pull/21314) +- Fix code colorizer ansi escape bug. by @jacob314 in + [#21321](https://github.com/google-gemini/gemini-cli/pull/21321) +- remove wildcard behavior on keybindings by @scidomino in + [#21315](https://github.com/google-gemini/gemini-cli/pull/21315) +- feat(acp): Add support for AI Gateway auth by @skeshive in + [#21305](https://github.com/google-gemini/gemini-cli/pull/21305) +- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in + [#21175](https://github.com/google-gemini/gemini-cli/pull/21175) +- feat (core): Implement tracker related SI changes by @anj-s in + [#19964](https://github.com/google-gemini/gemini-cli/pull/19964) +- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in + [#21333](https://github.com/google-gemini/gemini-cli/pull/21333) +- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in + [#21347](https://github.com/google-gemini/gemini-cli/pull/21347) +- docs: format release times as HH:MM UTC by @pavan-sh in + [#20726](https://github.com/google-gemini/gemini-cli/pull/20726) +- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in + [#21319](https://github.com/google-gemini/gemini-cli/pull/21319) +- docs: fix incorrect relative links to command reference by @kanywst in + [#20964](https://github.com/google-gemini/gemini-cli/pull/20964) +- documentiong ensures ripgrep by @Jatin24062005 in + [#21298](https://github.com/google-gemini/gemini-cli/pull/21298) +- fix(core): handle AbortError thrown during processTurn by @MumuTW in + [#21296](https://github.com/google-gemini/gemini-cli/pull/21296) +- docs(cli): clarify ! command output visibility in shell commands tutorial by + @MohammedADev in + [#21041](https://github.com/google-gemini/gemini-cli/pull/21041) +- fix: logic for task tracker strategy and remove tracker tools by @anj-s in + [#21355](https://github.com/google-gemini/gemini-cli/pull/21355) +- fix(partUtils): display media type and size for inline data parts by @Aboudjem + in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358) +- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in + [#20750](https://github.com/google-gemini/gemini-cli/pull/20750) +- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by + @Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439) +- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive + filesystems (#19904) by @Nixxx19 in + [#19915](https://github.com/google-gemini/gemini-cli/pull/19915) +- feat(core): add concurrency safety guidance for subagent delegation (#17753) + by @abhipatel12 in + [#21278](https://github.com/google-gemini/gemini-cli/pull/21278) +- feat(ui): dynamically generate all keybinding hints by @scidomino in + [#21346](https://github.com/google-gemini/gemini-cli/pull/21346) +- feat(core): implement unified KeychainService and migrate token storage by + @ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344) +- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in + [#21429](https://github.com/google-gemini/gemini-cli/pull/21429) +- fix(plan): keep approved plan during chat compression by @ruomengz in + [#21284](https://github.com/google-gemini/gemini-cli/pull/21284) +- feat(core): implement generic CacheService and optimize setupUser by @sehoon38 + in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374) +- Update quota and pricing documentation with subscription tiers by @srithreepo + in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351) +- fix(core): append correct OTLP paths for HTTP exporters by + @sebastien-prudhomme in + [#16836](https://github.com/google-gemini/gemini-cli/pull/16836) +- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in + [#21354](https://github.com/google-gemini/gemini-cli/pull/21354) +- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in + [#20979](https://github.com/google-gemini/gemini-cli/pull/20979) +- refactor(core): standardize MCP tool naming to mcp\_ FQN format by + @abhipatel12 in + [#21425](https://github.com/google-gemini/gemini-cli/pull/21425) +- feat(cli): hide gemma settings from display and mark as experimental by + @abhipatel12 in + [#21471](https://github.com/google-gemini/gemini-cli/pull/21471) +- feat(skills): refine string-reviewer guidelines and description by @clocky in + [#20368](https://github.com/google-gemini/gemini-cli/pull/20368) +- fix(core): whitelist TERM and COLORTERM in environment sanitization by + @deadsmash07 in + [#20514](https://github.com/google-gemini/gemini-cli/pull/20514) +- fix(billing): fix overage strategy lifecycle and settings integration by + @gsquared94 in + [#21236](https://github.com/google-gemini/gemini-cli/pull/21236) +- fix: expand paste placeholders in TextInput on submit by @Jefftree in + [#19946](https://github.com/google-gemini/gemini-cli/pull/19946) +- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by + @SandyTao520 in + [#21502](https://github.com/google-gemini/gemini-cli/pull/21502) +- feat(cli): overhaul thinking UI by @keithguerin in + [#18725](https://github.com/google-gemini/gemini-cli/pull/18725) +- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by + @jwhelangoog in + [#21474](https://github.com/google-gemini/gemini-cli/pull/21474) +- fix(cli): correct shell height reporting by @jacob314 in + [#21492](https://github.com/google-gemini/gemini-cli/pull/21492) +- Make test suite pass when the GEMINI_SYSTEM_MD env variable or + GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in + [#21480](https://github.com/google-gemini/gemini-cli/pull/21480) +- Disallow underspecified types by @gundermanc in + [#21485](https://github.com/google-gemini/gemini-cli/pull/21485) +- refactor(cli): standardize on 'reload' verb for all components by @keithguerin + in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654) +- feat(cli): Invert quota language to 'percent used' by @keithguerin in + [#20100](https://github.com/google-gemini/gemini-cli/pull/20100) +- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye + in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163) +- Code review comments as a pr by @jacob314 in + [#21209](https://github.com/google-gemini/gemini-cli/pull/21209) +- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in + [#20256](https://github.com/google-gemini/gemini-cli/pull/20256) +- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by + @Gyanranjan-Priyam in + [#21665](https://github.com/google-gemini/gemini-cli/pull/21665) +- fix(core): display actual graph output in tracker_visualize tool by @anj-s in + [#21455](https://github.com/google-gemini/gemini-cli/pull/21455) +- fix(core): sanitize SSE-corrupted JSON and domain strings in error + classification by @gsquared94 in + [#21702](https://github.com/google-gemini/gemini-cli/pull/21702) +- Docs: Make documentation links relative by @diodesign in + [#21490](https://github.com/google-gemini/gemini-cli/pull/21490) +- feat(cli): expose /tools desc as explicit subcommand for discoverability by + @aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241) +- feat(cli): add /compact alias for /compress command by @jackwotherspoon in + [#21711](https://github.com/google-gemini/gemini-cli/pull/21711) +- feat(plan): enable Plan Mode by default by @jerop in + [#21713](https://github.com/google-gemini/gemini-cli/pull/21713) +- feat(core): Introduce `AgentLoopContext`. by @joshualitt in + [#21198](https://github.com/google-gemini/gemini-cli/pull/21198) +- fix(core): resolve symlinks for non-existent paths during validation by + @Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487) +- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in + [#21428](https://github.com/google-gemini/gemini-cli/pull/21428) +- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38 + in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520) +- feat(cli): implement /upgrade command by @sehoon38 in + [#21511](https://github.com/google-gemini/gemini-cli/pull/21511) +- Feat/browser agent progress emission by @kunal-10-cloud in + [#21218](https://github.com/google-gemini/gemini-cli/pull/21218) +- fix(settings): display objects as JSON instead of [object Object] by + @Zheyuan-Lin in + [#21458](https://github.com/google-gemini/gemini-cli/pull/21458) +- Unmarshall update by @DavidAPierce in + [#21721](https://github.com/google-gemini/gemini-cli/pull/21721) +- Update mcp's list function to check for disablement. by @DavidAPierce in + [#21148](https://github.com/google-gemini/gemini-cli/pull/21148) +- robustness(core): static checks to validate history is immutable by @jacob314 + in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228) +- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in + [#21206](https://github.com/google-gemini/gemini-cli/pull/21206) +- feat(security): implement robust IP validation and safeFetch foundation by + @alisa-alisa in + [#21401](https://github.com/google-gemini/gemini-cli/pull/21401) +- feat(core): improve subagent result display by @joshualitt in + [#20378](https://github.com/google-gemini/gemini-cli/pull/20378) +- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in + [#20902](https://github.com/google-gemini/gemini-cli/pull/20902) +- feat(policy): support subagent-specific policies in TOML by @akh64bit in + [#21431](https://github.com/google-gemini/gemini-cli/pull/21431) +- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in + [#21748](https://github.com/google-gemini/gemini-cli/pull/21748) +- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in + [#21750](https://github.com/google-gemini/gemini-cli/pull/21750) +- fix(docs): fix headless mode docs by @ame2en in + [#21287](https://github.com/google-gemini/gemini-cli/pull/21287) +- feat/redesign header compact by @jacob314 in + [#20922](https://github.com/google-gemini/gemini-cli/pull/20922) +- refactor: migrate to useKeyMatchers hook by @scidomino in + [#21753](https://github.com/google-gemini/gemini-cli/pull/21753) +- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by + @sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521) +- fix(core): resolve Windows line ending and path separation bugs across CLI by + @muhammadusman586 in + [#21068](https://github.com/google-gemini/gemini-cli/pull/21068) +- docs: fix heading formatting in commands.md and phrasing in tools-api.md by + @campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679) +- refactor(ui): unify keybinding infrastructure and support string + initialization by @scidomino in + [#21776](https://github.com/google-gemini/gemini-cli/pull/21776) +- Add support for updating extension sources and names by @chrstnb in + [#21715](https://github.com/google-gemini/gemini-cli/pull/21715) +- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed + in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376) +- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy + in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693) +- fix(docs): update theme screenshots and add missing themes by @ashmod in + [#20689](https://github.com/google-gemini/gemini-cli/pull/20689) +- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in + [#21796](https://github.com/google-gemini/gemini-cli/pull/21796) +- build(release): restrict npm bundling to non-stable tags by @sehoon38 in + [#21821](https://github.com/google-gemini/gemini-cli/pull/21821) +- fix(core): override toolRegistry property for sub-agent schedulers by + @gsquared94 in + [#21766](https://github.com/google-gemini/gemini-cli/pull/21766) +- fix(cli): make footer items equally spaced by @jacob314 in + [#21843](https://github.com/google-gemini/gemini-cli/pull/21843) +- docs: clarify global policy rules application in plan mode by @jerop in + [#21864](https://github.com/google-gemini/gemini-cli/pull/21864) +- fix(core): ensure correct flash model steering in plan mode implementation + phase by @jerop in + [#21871](https://github.com/google-gemini/gemini-cli/pull/21871) +- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in + [#21875](https://github.com/google-gemini/gemini-cli/pull/21875) +- refactor(core): improve API response error logging when retry by @yunaseoul in + [#21784](https://github.com/google-gemini/gemini-cli/pull/21784) +- fix(ui): handle headless execution in credits and upgrade dialogs by + @gsquared94 in + [#21850](https://github.com/google-gemini/gemini-cli/pull/21850) +- fix(core): treat retryable errors with >5 min delay as terminal quota errors + by @gsquared94 in + [#21881](https://github.com/google-gemini/gemini-cli/pull/21881) +- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub + Actions by @cocosheng-g in + [#21129](https://github.com/google-gemini/gemini-cli/pull/21129) +- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by + @SandyTao520 in + [#21496](https://github.com/google-gemini/gemini-cli/pull/21496) +- feat(cli): give visibility to /tools list command in the TUI and follow the + subcommand pattern of other commands by @JayadityaGit in + [#21213](https://github.com/google-gemini/gemini-cli/pull/21213) +- Handle dirty worktrees better and warn about running scripts/review.sh on + untrusted code. by @jacob314 in + [#21791](https://github.com/google-gemini/gemini-cli/pull/21791) +- feat(policy): support auto-add to policy by default and scoped persistence by + @spencer426 in + [#20361](https://github.com/google-gemini/gemini-cli/pull/20361) +- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21 + in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863) +- fix(release): Improve Patch Release Workflow Comments: Clearer Approval + Guidance by @jerop in + [#21894](https://github.com/google-gemini/gemini-cli/pull/21894) +- docs: clarify telemetry setup and comprehensive data map by @jerop in + [#21879](https://github.com/google-gemini/gemini-cli/pull/21879) +- feat(core): add per-model token usage to stream-json output by @yongruilin in + [#21839](https://github.com/google-gemini/gemini-cli/pull/21839) +- docs: remove experimental badge from plan mode in sidebar by @jerop in + [#21906](https://github.com/google-gemini/gemini-cli/pull/21906) +- fix(cli): prevent race condition in loop detection retry by @skyvanguard in + [#17916](https://github.com/google-gemini/gemini-cli/pull/17916) +- Add behavioral evals for tracker by @anj-s in + [#20069](https://github.com/google-gemini/gemini-cli/pull/20069) +- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in + [#20892](https://github.com/google-gemini/gemini-cli/pull/20892) +- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in + [#21664](https://github.com/google-gemini/gemini-cli/pull/21664) +- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in + [#21852](https://github.com/google-gemini/gemini-cli/pull/21852) +- make command names consistent by @scidomino in + [#21907](https://github.com/google-gemini/gemini-cli/pull/21907) +- refactor: remove agent_card_requires_auth config flag by @adamfweidman in + [#21914](https://github.com/google-gemini/gemini-cli/pull/21914) +- feat(a2a): implement standardized normalization and streaming reassembly by + @alisa-alisa in + [#21402](https://github.com/google-gemini/gemini-cli/pull/21402) +- feat(cli): enable skill activation via slash commands by @NTaylorMullen in + [#21758](https://github.com/google-gemini/gemini-cli/pull/21758) +- docs(cli): mention per-model token usage in stream-json result event by + @yongruilin in + [#21908](https://github.com/google-gemini/gemini-cli/pull/21908) +- fix(plan): prevent plan truncation in approval dialog by supporting + unconstrained heights by @Adib234 in + [#21037](https://github.com/google-gemini/gemini-cli/pull/21037) +- feat(a2a): switch from callback-based to event-driven tool scheduler by + @cocosheng-g in + [#21467](https://github.com/google-gemini/gemini-cli/pull/21467) +- feat(voice): implement speech-friendly response formatter by @ayush31010 in + [#20989](https://github.com/google-gemini/gemini-cli/pull/20989) +- feat: add pulsating blue border automation overlay to browser agent by + @kunal-10-cloud in + [#21173](https://github.com/google-gemini/gemini-cli/pull/21173) +- Add extensionRegistryURI setting to change where the registry is read from by + @kevinjwang1 in + [#20463](https://github.com/google-gemini/gemini-cli/pull/20463) +- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in + [#21884](https://github.com/google-gemini/gemini-cli/pull/21884) +- fix: prevent hangs in non-interactive mode and improve agent guidance by + @cocosheng-g in + [#20893](https://github.com/google-gemini/gemini-cli/pull/20893) +- Add ExtensionDetails dialog and support install by @chrstnb in + [#20845](https://github.com/google-gemini/gemini-cli/pull/20845) +- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by @gemini-cli-robot in - [#21047](https://github.com/google-gemini/gemini-cli/pull/21047) -- fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch - version v0.33.0-preview.1 and create version 0.33.0-preview.2 by - @gemini-cli-robot in - [#21300](https://github.com/google-gemini/gemini-cli/pull/21300) -- fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171 + [#21816](https://github.com/google-gemini/gemini-cli/pull/21816) +- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in + [#21927](https://github.com/google-gemini/gemini-cli/pull/21927) +- fix(cli): stabilize prompt layout to prevent jumping when typing by + @NTaylorMullen in + [#21081](https://github.com/google-gemini/gemini-cli/pull/21081) +- fix: preserve prompt text when cancelling streaming by @Nixxx19 in + [#21103](https://github.com/google-gemini/gemini-cli/pull/21103) +- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in + [#20307](https://github.com/google-gemini/gemini-cli/pull/20307) +- feat: implement background process logging and cleanup by @galz10 in + [#21189](https://github.com/google-gemini/gemini-cli/pull/21189) +- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in + [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) +- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 [CONFLICTS] by @gemini-cli-robot in - [#21336](https://github.com/google-gemini/gemini-cli/pull/21336) -- fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch - version v0.33.0-preview.3 and create version 0.33.0-preview.4 by + [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) +- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch + version v0.34.0-preview.1 and create version 0.34.0-preview.2 by @gemini-cli-robot in - [#21349](https://github.com/google-gemini/gemini-cli/pull/21349) -- fix(patch): cherry-pick 931e668 to release/v0.33.0-preview.4-pr-21425 - [CONFLICTS] by @gemini-cli-robot in - [#21478](https://github.com/google-gemini/gemini-cli/pull/21478) -- fix(patch): cherry-pick 7837194 to release/v0.33.0-preview.5-pr-21487 to patch - version v0.33.0-preview.5 and create version 0.33.0-preview.6 by + [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) +- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch + version v0.34.0-preview.2 and create version 0.34.0-preview.3 by @gemini-cli-robot in - [#21720](https://github.com/google-gemini/gemini-cli/pull/21720) -- fix(patch): cherry-pick 4f4431e to release/v0.33.0-preview.7-pr-21750 to patch - version v0.33.0-preview.7 and create version 0.33.0-preview.8 by + [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) +- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch + version v0.34.0-preview.3 and create version 0.34.0-preview.4 by @gemini-cli-robot in - [#21782](https://github.com/google-gemini/gemini-cli/pull/21782) -- fix(patch): cherry-pick 9a74271 to release/v0.33.0-preview.8-pr-21236 - [CONFLICTS] by @gemini-cli-robot in - [#21788](https://github.com/google-gemini/gemini-cli/pull/21788) -- fix(patch): cherry-pick 936f624 to release/v0.33.0-preview.9-pr-21702 to patch - version v0.33.0-preview.9 and create version 0.33.0-preview.10 by - @gemini-cli-robot in - [#21800](https://github.com/google-gemini/gemini-cli/pull/21800) -- fix(patch): cherry-pick 35ee2a8 to release/v0.33.0-preview.10-pr-21713 by - @gemini-cli-robot in - [#21859](https://github.com/google-gemini/gemini-cli/pull/21859) -- fix(patch): cherry-pick 5dd2dab to release/v0.33.0-preview.11-pr-21871 by - @gemini-cli-robot in - [#21876](https://github.com/google-gemini/gemini-cli/pull/21876) -- fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to - patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 by - @gemini-cli-robot in - [#21922](https://github.com/google-gemini/gemini-cli/pull/21922) -- fix(patch): cherry-pick 1b69637 to release/v0.33.0-preview.13-pr-21467 - [CONFLICTS] by @gemini-cli-robot in - [#21930](https://github.com/google-gemini/gemini-cli/pull/21930) -- fix(patch): cherry-pick 3ff68a9 to release/v0.33.0-preview.14-pr-21884 - [CONFLICTS] by @gemini-cli-robot in - [#21952](https://github.com/google-gemini/gemini-cli/pull/21952) + [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.1 +https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 19ff7f8210..370ee8010a 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.34.0-preview.1 +# Preview release: v0.34.0-preview.4 -Released: March 12, 2026 +Released: March 16, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -28,6 +28,18 @@ npm install -g @google/gemini-cli@preview ## What's Changed +- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch + version v0.34.0-preview.3 and create version 0.34.0-preview.4 by + @gemini-cli-robot in + [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) +- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch + version v0.34.0-preview.2 and create version 0.34.0-preview.3 by + @gemini-cli-robot in + [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) +- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch + version v0.34.0-preview.1 and create version 0.34.0-preview.2 by + @gemini-cli-robot in + [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) - fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 [CONFLICTS] by @gemini-cli-robot in [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) @@ -468,4 +480,4 @@ npm install -g @google/gemini-cli@preview [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.1 +https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.4 diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index b46acaf966..9550e2a918 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -120,7 +120,8 @@ These are the only allowed tools: [`list_directory`](../tools/file-system.md#1-list_directory-readfolder), [`glob`](../tools/file-system.md#4-glob-findfiles) - **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext), - [`google_web_search`](../tools/web-search.md) + [`google_web_search`](../tools/web-search.md), + [`get_internal_docs`](../tools/internal-docs.md) - **Research Subagents:** [`codebase_investigator`](../core/subagents.md#codebase-investigator), [`cli_help`](../core/subagents.md#cli-help-agent) @@ -459,6 +460,26 @@ Manual deletion also removes all associated artifacts: If you use a [custom plans directory](#custom-plan-directory-and-policies), those files are not automatically deleted and must be managed manually. +## Non-interactive execution + +When running Gemini CLI in non-interactive environments (such as headless +scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows: + +- **Automatic transitions:** The policy engine automatically approves the + `enter_plan_mode` and `exit_plan_mode` tools without prompting for user + confirmation. +- **Automated implementation:** When exiting Plan Mode to execute the plan, + Gemini CLI automatically switches to + [YOLO mode](../reference/policy-engine.md#approval-modes) instead of the + standard Default mode. This allows the CLI to execute the implementation steps + automatically without hanging on interactive tool approvals. + +**Example:** + +```bash +gemini --approval-mode plan -p "Analyze telemetry and suggest improvements" +``` + [`plan.toml`]: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml [Conductor]: https://github.com/gemini-cli-extensions/conductor diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 11dbc17f09..d9b4fdf107 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -52,78 +52,21 @@ from your organization's registry. ### 3. Windows Native Sandbox (Windows only) -Built-in sandboxing for Windows using Restricted Tokens and Job Objects. This -method provides process isolation without requiring Docker or other container -runtimes. +... **Troubleshooting and Side Effects:** -**Prerequisites:** +The Windows Native sandbox uses the `icacls` command to set a "Low Mandatory +Level" on files and directories it needs to write to. -- Windows 10/11 or Windows Server. -- No additional software required (uses a built-in C# helper). - -**How it works:** - -The Windows native sandbox leverages: - -- **Restricted Tokens**: Strips administrator privileges and high-level SIDs - from the process. -- **Job Objects**: Ensures the entire process tree is terminated when the parent - session ends. -- **Mandatory Integrity Levels (Low)**: Restricts the process to "Low" - integrity, preventing it from writing to most of the system and workspace by - default. - -**Enabling Windows Native Sandbox:** - -```json -{ - "tools": { - "sandbox": { - "enabled": true, - "command": "windows-native" - } - } -} -``` - -Or via environment variable: - -```bash -$env:GEMINI_SANDBOX="windows-native" -``` - -**Permissions:** - -By default, the Windows native sandbox is restricted. If you need it to write to -specific directories, you must add them to `allowedPaths`: - -```json -{ - "tools": { - "sandbox": { - "enabled": true, - "command": "windows-native", - "allowedPaths": ["C:\\path\\to\\output"] - } - } -} -``` - -**Network Access:** - -Network access is disabled by default in "Strict" mode. To enable it: - -```json -{ - "tools": { - "sandbox": { - "enabled": true, - "command": "windows-native", - "networkAccess": true - } - } -} -``` +- **Persistence**: These integrity level changes are persistent on the + filesystem. Even after the sandbox session ends, files created or modified by + the sandbox will retain their "Low" integrity level. +- **Manual Reset**: If you need to reset the integrity level of a file or + directory, you can use: + ```powershell + icacls "C:\path\to\dir" /setintegritylevel Medium + ``` +- **System Folders**: The sandbox manager automatically skips setting integrity + levels on system folders (like `C:\Windows`) for safety. ### 4. gVisor / runsc (Linux only) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 35a09a99ab..eb9ba4158e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -125,7 +125,9 @@ they appear in the UI. | UI Label | Setting | Description | Default | | ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| Tool Sandboxing | `security.toolSandboxing` | Experimental tool-level sandboxing (implementation in progress). | `false` | | Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | +| Disable Always Allow | `security.disableAlwaysAllow` | Disable "Always allow" options in tool confirmation dialogs. | `false` | | Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | | Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` | | Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | @@ -150,6 +152,7 @@ they appear in the UI. | Plan | `experimental.plan` | Enable Plan Mode. | `true` | | Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | | Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | +| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | ### Skills diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 76c2806f9d..1f3edf716a 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -52,7 +52,7 @@ You tell Gemini about new servers by editing your `settings.json`. "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/modelcontextprotocol/servers/github:latest" + "ghcr.io/github/github-mcp-server:latest" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 659ed6d640..6d863f489e 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -7,20 +7,14 @@ the main agent's context or toolset. > **Note: Subagents are currently an experimental feature.** > -> To use custom subagents, you must explicitly enable them in your -> `settings.json`: +> To use custom subagents, you must ensure they are enabled in your +> `settings.json` (enabled by default): > > ```json > { > "experimental": { "enableAgents": true } > } > ``` -> -> **Warning:** Subagents currently operate in -> ["YOLO mode"](../reference/configuration.md#command-line-arguments), meaning -> they may execute tools without individual user confirmation for each step. -> Proceed with caution when defining agents with powerful tools like -> `run_shell_command` or `write_file`. ## What are subagents? diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 65dfe47c9b..6c712fd90d 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -677,6 +677,324 @@ their corresponding top-level category object in your `settings.json` file. used. - **Default:** `[]` +- **`modelConfigs.modelDefinitions`** (object): + - **Description:** Registry of model metadata, including tier, family, and + features. + - **Default:** + + ```json + { + "gemini-3.1-pro-preview": { + "tier": "pro", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": true, + "multimodalToolUse": true + } + }, + "gemini-3.1-pro-preview-customtools": { + "tier": "pro", + "family": "gemini-3", + "isPreview": true, + "isVisible": false, + "features": { + "thinking": true, + "multimodalToolUse": true + } + }, + "gemini-3-pro-preview": { + "tier": "pro", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": true, + "multimodalToolUse": true + } + }, + "gemini-3-flash-preview": { + "tier": "flash", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, + "gemini-2.5-pro": { + "tier": "pro", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "gemini-2.5-flash": { + "tier": "flash", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "gemini-2.5-flash-lite": { + "tier": "flash-lite", + "family": "gemini-2.5", + "isPreview": false, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "auto": { + "tier": "auto", + "isPreview": true, + "isVisible": false, + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "pro": { + "tier": "pro", + "isPreview": false, + "isVisible": false, + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "flash": { + "tier": "flash", + "isPreview": false, + "isVisible": false, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "flash-lite": { + "tier": "flash-lite", + "isPreview": false, + "isVisible": false, + "features": { + "thinking": false, + "multimodalToolUse": false + } + }, + "auto-gemini-3": { + "displayName": "Auto (Gemini 3)", + "tier": "auto", + "isPreview": true, + "isVisible": true, + "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash", + "features": { + "thinking": true, + "multimodalToolUse": false + } + }, + "auto-gemini-2.5": { + "displayName": "Auto (Gemini 2.5)", + "tier": "auto", + "isPreview": false, + "isVisible": true, + "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash", + "features": { + "thinking": false, + "multimodalToolUse": false + } + } + } + ``` + + - **Requires restart:** Yes + +- **`modelConfigs.modelIdResolutions`** (object): + - **Description:** Rules for resolving requested model names to concrete model + IDs based on context. + - **Default:** + + ```json + { + "gemini-3-pro-preview": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "auto-gemini-3": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "auto": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "pro": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + }, + "auto-gemini-2.5": { + "default": "gemini-2.5-pro" + }, + "flash": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + } + ] + }, + "flash-lite": { + "default": "gemini-2.5-flash-lite" + } + } + ``` + + - **Requires restart:** Yes + +- **`modelConfigs.classifierIdResolutions`** (object): + - **Description:** Rules for resolving classifier tiers (flash, pro) to + concrete model IDs. + - **Default:** + + ```json + { + "flash": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"] + }, + "target": "gemini-2.5-flash" + }, + { + "condition": { + "requestedModels": ["auto-gemini-3", "gemini-3-pro-preview"] + }, + "target": "gemini-3-flash-preview" + } + ] + }, + "pro": { + "default": "gemini-3-pro-preview", + "contexts": [ + { + "condition": { + "requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"] + }, + "target": "gemini-2.5-pro" + }, + { + "condition": { + "useGemini3_1": true, + "useCustomTools": true + }, + "target": "gemini-3.1-pro-preview-customtools" + }, + { + "condition": { + "useGemini3_1": true + }, + "target": "gemini-3.1-pro-preview" + } + ] + } + } + ``` + + - **Requires restart:** Yes + #### `agents` - **`agents.overrides`** (object): @@ -706,6 +1024,17 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `undefined` - **Requires restart:** Yes +- **`agents.browser.allowedDomains`** (array): + - **Description:** A list of allowed domains for the browser agent (e.g., + ["github.com", "*.google.com"]). + - **Default:** + + ```json + ["github.com", "*.google.com", "localhost"] + ``` + + - **Requires restart:** Yes + - **`agents.browser.disableUserInput`** (boolean): - **Description:** Disable user input on browser window during automation. - **Default:** `true` @@ -773,10 +1102,10 @@ their corresponding top-level category object in your `settings.json` file. #### `tools` - **`tools.sandbox`** (string): - - **Description:** Sandbox execution environment. Set to a boolean to enable - or disable the sandbox, provide a string path to a sandbox profile, or - specify an explicit sandbox command (e.g., "docker", "podman", "lxc", - "windows-native"). + - **Description:** Legacy full-process sandbox execution environment. Set to a + boolean to enable or disable the sandbox, provide a string path to a sandbox + profile, or specify an explicit sandbox command (e.g., "docker", "podman", + "lxc", "windows-native"). - **Default:** `undefined` - **Requires restart:** Yes @@ -891,11 +1220,22 @@ their corresponding top-level category object in your `settings.json` file. #### `security` +- **`security.toolSandboxing`** (boolean): + - **Description:** Experimental tool-level sandboxing (implementation in + progress). + - **Default:** `false` + - **`security.disableYoloMode`** (boolean): - **Description:** Disable YOLO mode, even if enabled by a flag. - **Default:** `false` - **Requires restart:** Yes +- **`security.disableAlwaysAllow`** (boolean): + - **Description:** Disable "Always allow" options in tool confirmation + dialogs. + - **Default:** `false` + - **Requires restart:** Yes + - **`security.enablePermanentToolApproval`** (boolean): - **Description:** Enable the "Allow for all future sessions" option in tool confirmation dialogs. @@ -1012,9 +1352,8 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes - **`experimental.enableAgents`** (boolean): - - **Description:** Enable local and remote subagents. Warning: Experimental - feature, uses YOLO mode for subagents - - **Default:** `false` + - **Description:** Enable local and remote subagents. + - **Default:** `true` - **Requires restart:** Yes - **`experimental.extensionManagement`** (boolean): @@ -1045,7 +1384,7 @@ their corresponding top-level category object in your `settings.json` file. - **`experimental.jitContext`** (boolean): - **Description:** Enable Just-In-Time (JIT) context loading. - - **Default:** `false` + - **Default:** `true` - **Requires restart:** Yes - **`experimental.useOSC52Paste`** (boolean): @@ -1080,6 +1419,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`experimental.dynamicModelConfiguration`** (boolean): + - **Description:** Enable dynamic model configuration (definitions, + resolutions, and chains) via settings. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.gemmaModelRouter.enabled`** (boolean): - **Description:** Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. @@ -1097,6 +1442,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `"gemma3-1b-gpu-custom"` - **Requires restart:** Yes +- **`experimental.topicUpdateNarration`** (boolean): + - **Description:** Enable the experimental Topic & Update communication model + for reduced chattiness and structured progress reporting. + - **Default:** `false` + #### `skills` - **`skills.enabled`** (boolean): @@ -1186,7 +1536,8 @@ their corresponding top-level category object in your `settings.json` file. #### `admin` - **`admin.secureModeEnabled`** (boolean): - - **Description:** If true, disallows yolo mode from being used. + - **Description:** If true, disallows YOLO mode and "Always allow" options + from being used. - **Default:** `false` - **`admin.extensions.enabled`** (boolean): diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 9b63c89f62..fb97b5e071 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -60,7 +60,7 @@ command. ```toml [[rule]] toolName = "run_shell_command" -commandPrefix = "git " +commandPrefix = "git" decision = "ask_user" priority = 100 ``` @@ -90,6 +90,17 @@ If `argsPattern` is specified, the tool's arguments are converted to a stable JSON string, which is then tested against the provided regular expression. If the arguments don't match the pattern, the rule does not apply. +#### Execution environment + +If `interactive` is specified, the rule will only apply if the CLI's execution +environment matches the specified boolean value: + +- `true`: The rule applies only in interactive mode. +- `false`: The rule applies only in non-interactive (headless) mode. + +If omitted, the rule applies to both interactive and non-interactive +environments. + ### Decisions There are three possible decisions a rule can enforce: @@ -264,7 +275,7 @@ argsPattern = '"command":"(git|npm)' # (Optional) A string or array of strings that a shell command must start with. # This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`. -commandPrefix = "git " +commandPrefix = "git" # (Optional) A regex to match against the entire shell command. # This is also syntactic sugar for `toolName = "run_shell_command"`. @@ -286,6 +297,10 @@ deny_message = "Deletion is permanent" # (Optional) An array of approval modes where this rule is active. modes = ["autoEdit"] + +# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments. +# If omitted, the rule applies to both. +interactive = true ``` ### Using arrays (lists) @@ -321,7 +336,7 @@ This rule will ask for user confirmation before executing any `git` command. ```toml [[rule]] toolName = "run_shell_command" -commandPrefix = "git " +commandPrefix = "git" decision = "ask_user" priority = 100 ``` @@ -360,6 +375,8 @@ priority = 200 Specify only the `mcpName` to apply a rule to every tool provided by that server. +**Note:** This applies to all decision types (`allow`, `deny`, `ask_user`). + ```toml # Denies all tools from the `untrusted-server` MCP [[rule]] diff --git a/docs/tools/ask-user.md b/docs/tools/ask-user.md index 8c086acdba..14770b4c99 100644 --- a/docs/tools/ask-user.md +++ b/docs/tools/ask-user.md @@ -25,7 +25,8 @@ confirmation. - `label` (string, required): Display text (1-5 words). - `description` (string, required): Brief explanation. - `multiSelect` (boolean, optional): For `'choice'` type, allows selecting - multiple options. + multiple options. Automatically adds an "All the above" option if there + are multiple standard options. - `placeholder` (string, optional): Hint text for input fields. - **Behavior:** diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 6b8cd22ac0..5cdbbacf1c 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -729,6 +729,43 @@ tools. The model will automatically: The MCP integration tracks several states: +#### Overriding extension configurations + +If an MCP server is provided by an extension (for example, the +`google-workspace` extension), you can still override its settings in your local +`settings.json`. Gemini CLI merges your local configuration with the extension's +defaults: + +- **Tool lists:** Tool lists are merged securely to ensure the most restrictive + policy wins: + - **Exclusions (`excludeTools`):** Arrays are combined (unioned). If either + source blocks a tool, it remains disabled. + - **Inclusions (`includeTools`):** Arrays are intersected. If both sources + provide an allowlist, only tools present in **both** lists are enabled. If + only one source provides an allowlist, that list is respected. + - **Precedence:** `excludeTools` always takes precedence over `includeTools`. + + This ensures you always have veto power over tools provided by an extension + and that an extension cannot re-enable tools you have omitted from your + personal allowlist. + +- **Environment variables:** The `env` objects are merged. If the same variable + is defined in both places, your local value takes precedence. +- **Scalar properties:** Properties like `command`, `url`, and `timeout` are + replaced by your local values if provided. + +**Example override:** + +```json +{ + "mcpServers": { + "google-workspace": { + "excludeTools": ["gmail.send"] + } + } +} +``` + #### Server status (`MCPServerStatus`) - **`DISCONNECTED`:** Server is not connected or has errors diff --git a/docs/tools/todos.md b/docs/tools/todos.md index abb44c0927..d198b872ea 100644 --- a/docs/tools/todos.md +++ b/docs/tools/todos.md @@ -13,7 +13,8 @@ updates to the CLI interface. - `todos` (array of objects, required): The complete list of tasks. Each object includes: - `description` (string): Technical description of the task. - - `status` (enum): `pending`, `in_progress`, `completed`, or `cancelled`. + - `status` (enum): `pending`, `in_progress`, `completed`, `cancelled`, or + `blocked`. ## Technical behavior diff --git a/eslint.config.js b/eslint.config.js index d879a5da8e..76230fdfe5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -51,6 +51,7 @@ export default tseslint.config( 'evals/**', 'packages/test-utils/**', '.gemini/skills/**', + '**/*.d.ts', ], }, eslint.configs.recommended, @@ -206,11 +207,26 @@ export default tseslint.config( { // Rules that only apply to product code files: ['packages/*/src/**/*.{ts,tsx}'], - ignores: ['**/*.test.ts', '**/*.test.tsx'], + ignores: ['**/*.test.ts', '**/*.test.tsx', 'packages/*/src/test-utils/**'], rules: { '@typescript-eslint/no-unsafe-type-assertion': 'error', '@typescript-eslint/no-unsafe-assignment': 'error', '@typescript-eslint/no-unsafe-return': 'error', + 'no-restricted-syntax': [ + 'error', + ...commonRestrictedSyntaxRules, + { + selector: + 'CallExpression[callee.object.name="Object"][callee.property.name="create"]', + message: + 'Avoid using Object.create() in product code. Use object spread {...obj}, explicit class instantiation, structuredClone(), or copy constructors instead.', + }, + { + selector: 'Identifier[name="Reflect"]', + message: + 'Avoid using Reflect namespace in product code. Do not use reflection to make copies. Instead, use explicit object copying or cloning (structuredClone() for values, new instance/clone function for classes).', + }, + ], }, }, { @@ -303,7 +319,12 @@ export default tseslint.config( }, }, { - files: ['./scripts/**/*.js', 'packages/*/scripts/**/*.js', 'esbuild.config.js'], + files: [ + './scripts/**/*.js', + 'packages/*/scripts/**/*.js', + 'esbuild.config.js', + 'packages/core/scripts/**/*.{js,mjs}', + ], languageOptions: { globals: { ...globals.node, diff --git a/evals/answer-vs-act.eval.ts b/evals/answer-vs-act.eval.ts index 4e30b828d0..ff87d12564 100644 --- a/evals/answer-vs-act.eval.ts +++ b/evals/answer-vs-act.eval.ts @@ -111,7 +111,7 @@ describe('Answer vs. ask eval', () => { * Ensures that when the user asks a question about style, the agent does NOT * automatically modify the file. */ - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should not edit files when asked about style', prompt: 'Is app.ts following good style?', files: FILES, diff --git a/evals/hierarchical_memory.eval.ts b/evals/hierarchical_memory.eval.ts index ff7483416b..dd4f8fbbd1 100644 --- a/evals/hierarchical_memory.eval.ts +++ b/evals/hierarchical_memory.eval.ts @@ -11,7 +11,7 @@ import { assertModelHasOutput } from '../integration-tests/test-helper.js'; describe('Hierarchical Memory', () => { const conflictResolutionTest = 'Agent follows hierarchy for contradictory instructions'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: conflictResolutionTest, params: { settings: { diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts index 29566eab86..a37e5f91b4 100644 --- a/evals/plan_mode.eval.ts +++ b/evals/plan_mode.eval.ts @@ -18,6 +18,18 @@ describe('plan_mode', () => { experimental: { plan: true }, }; + const getWriteTargets = (logs: any[]) => + logs + .filter((log) => ['write_file', 'replace'].includes(log.toolRequest.name)) + .map((log) => { + try { + return JSON.parse(log.toolRequest.args).file_path as string; + } catch { + return ''; + } + }) + .filter(Boolean); + evalTest('ALWAYS_PASSES', { name: 'should refuse file modification when in plan mode', approvalMode: ApprovalMode.PLAN, @@ -32,27 +44,23 @@ describe('plan_mode', () => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); - const writeTargets = toolLogs - .filter((log) => - ['write_file', 'replace'].includes(log.toolRequest.name), - ) - .map((log) => { - try { - return JSON.parse(log.toolRequest.args).file_path; - } catch { - return null; - } - }); + const exitPlanIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'exit_plan_mode', + ); + + const writeTargetsBeforeExitPlan = getWriteTargets( + toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), + ); expect( - writeTargets, + writeTargetsBeforeExitPlan, 'Should not attempt to modify README.md in plan mode', ).not.toContain('README.md'); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i], - testName: `${TEST_PREFIX}should refuse file modification`, + testName: `${TEST_PREFIX}should refuse file modification in plan mode`, }); }, }); @@ -69,24 +77,20 @@ describe('plan_mode', () => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); - const writeTargets = toolLogs - .filter((log) => - ['write_file', 'replace'].includes(log.toolRequest.name), - ) - .map((log) => { - try { - return JSON.parse(log.toolRequest.args).file_path; - } catch { - return null; - } - }); + const exitPlanIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'exit_plan_mode', + ); + + const writeTargetsBeforeExit = getWriteTargets( + toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), + ); // It should NOT write to the docs folder or any other repo path - const hasRepoWrite = writeTargets.some( + const hasRepoWriteBeforeExit = writeTargetsBeforeExit.some( (path) => path && !path.includes('/plans/'), ); expect( - hasRepoWrite, + hasRepoWriteBeforeExit, 'Should not attempt to create files in the repository while in plan mode', ).toBe(false); @@ -166,4 +170,65 @@ describe('plan_mode', () => { assertModelHasOutput(result); }, }); + + evalTest('USUALLY_PASSES', { + name: 'should create a plan in plan mode and implement it for a refactoring task', + params: { + settings, + }, + files: { + 'src/mathUtils.ts': + 'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;', + 'src/main.ts': + 'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));', + }, + prompt: + 'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.', + assert: async (rig, result) => { + const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode'); + expect( + enterPlanCalled, + 'Expected enter_plan_mode tool to be called', + ).toBe(true); + + const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode'); + expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe( + true, + ); + + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + // Check if plan was written + const planWrite = toolLogs.find( + (log) => + log.toolRequest.name === 'write_file' && + log.toolRequest.args.includes('/plans/'), + ); + expect( + planWrite, + 'Expected a plan file to be written in the plans directory', + ).toBeDefined(); + + // Check for implementation files + const newFileWrite = toolLogs.find( + (log) => + log.toolRequest.name === 'write_file' && + log.toolRequest.args.includes('src/basicMath.ts'), + ); + expect( + newFileWrite, + 'Expected src/basicMath.ts to be created', + ).toBeDefined(); + + const mainUpdate = toolLogs.find( + (log) => + ['write_file', 'replace'].includes(log.toolRequest.name) && + log.toolRequest.args.includes('src/main.ts'), + ); + expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined(); + + assertModelHasOutput(result); + }, + }); }); diff --git a/evals/save_memory.eval.ts b/evals/save_memory.eval.ts index e4fe9bc687..901cbf3c17 100644 --- a/evals/save_memory.eval.ts +++ b/evals/save_memory.eval.ts @@ -14,7 +14,7 @@ import { describe('save_memory', () => { const TEST_PREFIX = 'Save memory test: '; const rememberingFavoriteColor = "Agent remembers user's favorite color"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingFavoriteColor, params: { settings: { tools: { core: ['save_memory'] } }, @@ -79,7 +79,7 @@ describe('save_memory', () => { const ignoringTemporaryInformation = 'Agent ignores temporary conversation details'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: ignoringTemporaryInformation, params: { settings: { tools: { core: ['save_memory'] } }, @@ -104,7 +104,7 @@ describe('save_memory', () => { }); const rememberingPetName = "Agent remembers user's pet's name"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingPetName, params: { settings: { tools: { core: ['save_memory'] } }, diff --git a/integration-tests/extensions-install.test.ts b/integration-tests/extensions-install.test.ts index 9aceeb6564..90dbf1ab0d 100644 --- a/integration-tests/extensions-install.test.ts +++ b/integration-tests/extensions-install.test.ts @@ -42,11 +42,10 @@ describe('extension install', () => { const listResult = await rig.runCommand(['extensions', 'list']); expect(listResult).toContain('test-extension-install'); writeFileSync(testServerPath, extensionUpdate); - const updateResult = await rig.runCommand([ - 'extensions', - 'update', - `test-extension-install`, - ]); + const updateResult = await rig.runCommand( + ['extensions', 'update', `test-extension-install`], + { stdin: 'y\n' }, + ); expect(updateResult).toContain('0.0.2'); } finally { await rig.runCommand([ diff --git a/package-lock.json b/package-lock.json index 7cc458581b..914d66d3ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "workspaces": [ "packages/*" ], @@ -3038,6 +3038,27 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -3762,6 +3783,12 @@ "node": ">= 10" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@ts-morph/common": { "version": "0.12.3", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.12.3.tgz", @@ -3949,6 +3976,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-stable-stringify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.1.0.tgz", + "integrity": "sha512-ESTsHWB72QQq+pjUFIbEz9uSCZppD31YrVkbt2rnUciTYEvcwN6uZIhX5JZeBHqRlFJ41x/7MewCs7E2Qux6Cg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -5584,6 +5618,18 @@ "node": ">=12" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", @@ -5676,6 +5722,20 @@ "typed-rest-client": "^1.8.4" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5685,6 +5745,93 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz", + "integrity": "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", + "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.21.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5705,6 +5852,15 @@ ], "license": "MIT" }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -5895,7 +6051,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -6103,6 +6258,32 @@ "node": ">=18" } }, + "node_modules/chrome-devtools-mcp": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-0.19.0.tgz", + "integrity": "sha512-LfqjOxdUjWvCQrfeI5V3ZBJCUIDKGNmexSbSAgsrjVggN4X1OSObLxleSlX2zwcXRZYxqy209cww0MXcXuN1zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "chrome-devtools-mcp": "build/src/index.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", @@ -6901,7 +7082,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -6945,6 +7125,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7204,6 +7398,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1581282", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", + "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", + "license": "BSD-3-Clause" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -7759,6 +7959,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.29.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", @@ -8118,7 +8339,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -8137,7 +8357,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -8189,6 +8408,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8395,6 +8623,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -9037,6 +9271,29 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/glob": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", @@ -9461,7 +9718,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -9663,7 +9919,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -10577,7 +10832,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -10801,6 +11055,25 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10849,6 +11122,15 @@ "node": ">= 10.0.0" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -11759,6 +12041,12 @@ "node": ">= 18" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -11959,6 +12247,15 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-addon-api": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", @@ -12401,7 +12698,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12662,6 +12958,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", @@ -13132,6 +13460,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -13237,6 +13574,40 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -13290,6 +13661,45 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "24.39.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.0.tgz", + "integrity": "sha512-SzIxz76Kgu17HUIi57HOejPiN0JKa9VCd2GcPY1sAh6RA4BzGZarFQdOYIYrBdUVbtyH7CrDb9uhGEwVXK/YNA==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1581282", + "typed-query-selector": "^2.12.1", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -14250,9 +14660,9 @@ } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14317,7 +14727,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -14583,6 +14992,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -14711,6 +15168,17 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -15308,6 +15776,32 @@ "node": ">=8" } }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/teeny-request": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", @@ -15363,6 +15857,15 @@ "node": ">= 6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -15395,6 +15898,15 @@ "node": ">=18" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -15719,7 +16231,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -15869,6 +16380,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", + "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", + "license": "MIT" + }, "node_modules/typed-rest-client": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", @@ -16336,6 +16853,12 @@ } } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -16890,7 +17413,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "dependencies": { "@a2a-js/sdk": "0.3.11", "@google-cloud/storage": "^7.16.0", @@ -17005,7 +17528,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", @@ -17177,7 +17700,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "0.3.11", @@ -17226,12 +17749,14 @@ "ignore": "^7.0.0", "ipaddr.js": "^1.9.1", "js-yaml": "^4.1.1", + "json-stable-stringify": "^1.3.0", "marked": "^15.0.12", "mime": "4.0.7", "mnemonist": "^0.40.3", "open": "^10.1.2", "picomatch": "^4.0.1", "proper-lockfile": "^4.1.2", + "puppeteer-core": "^24.0.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", "simple-git": "^3.28.0", @@ -17249,7 +17774,9 @@ "@google/gemini-cli-test-utils": "file:../test-utils", "@types/fast-levenshtein": "^0.0.4", "@types/js-yaml": "^4.0.9", + "@types/json-stable-stringify": "^1.1.0", "@types/picomatch": "^4.0.1", + "chrome-devtools-mcp": "^0.19.0", "msw": "^2.3.4", "typescript": "^5.3.3", "vitest": "^3.1.1" @@ -17439,7 +17966,7 @@ }, "packages/devtools": { "name": "@google/gemini-cli-devtools", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { "ws": "^8.16.0" @@ -17454,7 +17981,7 @@ }, "packages/sdk": { "name": "@google/gemini-cli-sdk", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17471,7 +17998,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17488,7 +18015,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", diff --git a/package.json b/package.json index 0067054629..531f9f75d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "engines": { "node": ">=20.0.0" }, @@ -14,7 +14,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", @@ -43,6 +43,7 @@ "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:sea-launch": "vitest run sea/sea-launch.test.js", + "posttest": "npm run build", "test:always_passing_evals": "vitest run --config evals/vitest.config.ts", "test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts", "test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none", diff --git a/packages/a2a-server/GEMINI.md b/packages/a2a-server/GEMINI.md new file mode 100644 index 0000000000..34e487e3bb --- /dev/null +++ b/packages/a2a-server/GEMINI.md @@ -0,0 +1,22 @@ +# Gemini CLI A2A Server (`@google/gemini-cli-a2a-server`) + +Experimental Agent-to-Agent (A2A) server that exposes Gemini CLI capabilities +over HTTP for inter-agent communication. + +## Architecture + +- `src/agent/`: Agent session management for A2A interactions. +- `src/commands/`: CLI command definitions for the A2A server binary. +- `src/config/`: Server configuration. +- `src/http/`: HTTP server and route handlers. +- `src/persistence/`: Session and state persistence. +- `src/utils/`: Shared utility functions. +- `src/types.ts`: Shared type definitions. + +## Running + +- Binary entry point: `gemini-cli-a2a-server` + +## Testing + +- Run tests: `npm test -w @google/gemini-cli-a2a-server` diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index ecf3ee3d66..5257e56240 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "description": "Gemini CLI A2A Server", "repository": { "type": "git", diff --git a/packages/a2a-server/src/commands/memory.test.ts b/packages/a2a-server/src/commands/memory.test.ts index 2d3a5fef91..de5a09fcb2 100644 --- a/packages/a2a-server/src/commands/memory.test.ts +++ b/packages/a2a-server/src/commands/memory.test.ts @@ -177,10 +177,13 @@ describe('a2a-server memory commands', () => { expect.any(AbortSignal), undefined, { - sanitizationConfig: { - allowedEnvironmentVariables: [], - blockedEnvironmentVariables: [], - enableEnvironmentVariableRedaction: false, + shellExecutionConfig: { + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + sandboxManager: undefined, }, }, ); diff --git a/packages/a2a-server/src/commands/memory.ts b/packages/a2a-server/src/commands/memory.ts index d01ff5e7d4..f84d57b3fc 100644 --- a/packages/a2a-server/src/commands/memory.ts +++ b/packages/a2a-server/src/commands/memory.ts @@ -103,7 +103,10 @@ export class AddMemoryCommand implements Command { const abortController = new AbortController(); const signal = abortController.signal; await tool.buildAndExecute(result.toolArgs, signal, undefined, { - sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + shellExecutionConfig: { + sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + sandboxManager: loopContext.sandboxManager, + }, }); await refreshMemory(context.config); return { diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index bd8771d1b5..cfe77311ea 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -19,6 +19,8 @@ import { AuthType, isHeadlessMode, FatalAuthenticationError, + PolicyDecision, + PRIORITY_YOLO_ALLOW_ALL, } from '@google/gemini-cli-core'; // Mock dependencies @@ -325,6 +327,29 @@ describe('loadConfig', () => { ); }); + it('should pass enableAgents to Config constructor', async () => { + const settings: Settings = { + experimental: { + enableAgents: false, + }, + }; + await loadConfig(settings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + enableAgents: false, + }), + ); + }); + + it('should default enableAgents to true when not provided', async () => { + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + enableAgents: true, + }), + ); + }); + describe('interactivity', () => { it('should set interactive true when not headless', async () => { vi.mocked(isHeadlessMode).mockReturnValue(false); @@ -349,6 +374,41 @@ describe('loadConfig', () => { }); }); + describe('YOLO mode', () => { + it('should enable YOLO mode and add policy rule when GEMINI_YOLO_MODE is true', async () => { + vi.stubEnv('GEMINI_YOLO_MODE', 'true'); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + approvalMode: 'yolo', + policyEngineConfig: expect.objectContaining({ + rules: expect.arrayContaining([ + expect.objectContaining({ + decision: PolicyDecision.ALLOW, + priority: PRIORITY_YOLO_ALLOW_ALL, + modes: ['yolo'], + allowRedirection: true, + }), + ]), + }), + }), + ); + }); + + it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => { + vi.stubEnv('GEMINI_YOLO_MODE', 'false'); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + approvalMode: 'default', + policyEngineConfig: expect.objectContaining({ + rules: [], + }), + }), + ); + }); + }); + describe('authentication fallback', () => { beforeEach(() => { vi.stubEnv('USE_CCPA', 'true'); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 607695f173..9474c4d9c5 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -26,6 +26,8 @@ import { isHeadlessMode, FatalAuthenticationError, isCloudShell, + PolicyDecision, + PRIORITY_YOLO_ALLOW_ALL, type TelemetryTarget, type ConfigParameters, type ExtensionLoader, @@ -60,6 +62,11 @@ export async function loadConfig( } } + const approvalMode = + process.env['GEMINI_YOLO_MODE'] === 'true' + ? ApprovalMode.YOLO + : ApprovalMode.DEFAULT; + const configParams: ConfigParameters = { sessionId: taskId, clientName: 'a2a-server', @@ -74,10 +81,20 @@ export async function loadConfig( excludeTools: settings.excludeTools || settings.tools?.exclude || undefined, allowedTools: settings.allowedTools || settings.tools?.allowed || undefined, showMemoryUsage: settings.showMemoryUsage || false, - approvalMode: - process.env['GEMINI_YOLO_MODE'] === 'true' - ? ApprovalMode.YOLO - : ApprovalMode.DEFAULT, + approvalMode, + policyEngineConfig: { + rules: + approvalMode === ApprovalMode.YOLO + ? [ + { + decision: PolicyDecision.ALLOW, + priority: PRIORITY_YOLO_ALLOW_ALL, + modes: [ApprovalMode.YOLO], + allowRedirection: true, + }, + ] + : [], + }, mcpServers: settings.mcpServers, cwd: workspaceDir, telemetry: { @@ -110,6 +127,7 @@ export async function loadConfig( interactive: !isHeadlessMode(), enableInteractiveShell: !isHeadlessMode(), ptyInfo: 'auto', + enableAgents: settings.experimental?.enableAgents ?? true, }; const fileService = new FileDiscoveryService(workspaceDir, { diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts index 7c51950535..ab80bced24 100644 --- a/packages/a2a-server/src/config/settings.test.ts +++ b/packages/a2a-server/src/config/settings.test.ts @@ -112,6 +112,18 @@ describe('loadSettings', () => { expect(result.fileFiltering?.respectGitIgnore).toBe(true); }); + it('should load experimental settings correctly', () => { + const settings = { + experimental: { + enableAgents: true, + }, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); + + const result = loadSettings(mockWorkspaceDir); + expect(result.experimental?.enableAgents).toBe(true); + }); + it('should overwrite top-level settings from workspace (shallow merge)', () => { const userSettings = { showMemoryUsage: false, diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index da9db4e069..ced11a4daa 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -48,6 +48,9 @@ export interface Settings { enableRecursiveFileSearch?: boolean; customIgnoreFilePaths?: string[]; }; + experimental?: { + enableAgents?: boolean; + }; } export interface SettingsError { diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index c55eae98ee..fd4d721732 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -21,7 +21,9 @@ import { tmpdir, type Config, type Storage, + NoopSandboxManager, type ToolRegistry, + type SandboxManager, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; import { expect, vi } from 'vitest'; @@ -97,6 +99,15 @@ export function createMockConfig( }), getGitService: vi.fn(), validatePathAccess: vi.fn().mockReturnValue(undefined), + getShellExecutionConfig: vi.fn().mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + sandboxManager: new NoopSandboxManager() as unknown as SandboxManager, + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), ...overrides, } as unknown as Config; diff --git a/packages/cli/GEMINI.md b/packages/cli/GEMINI.md index 5518696d60..e98ca81376 100644 --- a/packages/cli/GEMINI.md +++ b/packages/cli/GEMINI.md @@ -5,7 +5,7 @@ - Always fix react-hooks/exhaustive-deps lint errors by adding the missing dependencies. - **Shortcuts**: only define keyboard shortcuts in - `packages/cli/src/config/keyBindings.ts` + `packages/cli/src/ui/key/keyBindings.ts` - Do not implement any logic performing custom string measurement or string truncation. Use Ink layout instead leveraging ResizeObserver as needed. - Avoid prop drilling when at all possible. diff --git a/packages/cli/package.json b/packages/cli/package.json index 648c4751e5..79cb21307a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.35.0-nightly.20260311.657f19c1f", + "version": "0.36.0-nightly.20260317.2f90b4653", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -20,13 +20,14 @@ "format": "prettier --write .", "test": "vitest run", "test:ci": "vitest run", + "posttest": "npm run build", "typecheck": "tsc --noEmit" }, "files": [ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653" }, "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index e2fc0f0d33..65b23247ef 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -176,6 +176,7 @@ describe('GeminiAgent', () => { getGemini31LaunchedSync: vi.fn().mockReturnValue(false), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), } as unknown as Mocked>>; mockSettings = { merged: { @@ -654,6 +655,7 @@ describe('Session', () => { getCheckpointingEnabled: vi.fn().mockReturnValue(false), getGitService: vi.fn().mockResolvedValue({} as GitService), waitForMcpInit: vi.fn(), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), } as unknown as Mocked; mockConnection = { sessionUpdate: vi.fn(), @@ -947,6 +949,61 @@ describe('Session', () => { ); }); + it('should exclude always allow options when disableAlwaysAllow is true', async () => { + mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(true); + const confirmationDetails = { + type: 'info', + onConfirm: vi.fn(), + }; + mockTool.build.mockReturnValue({ + getDescription: () => 'Test Tool', + toolLocations: () => [], + shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails), + execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }), + }); + + mockConnection.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: ToolConfirmationOutcome.ProceedOnce, + }, + }); + + const stream1 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + functionCalls: [{ name: 'test_tool', args: {} }], + }, + }, + ]); + const stream2 = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + + mockChat.sendMessageStream + .mockResolvedValueOnce(stream1) + .mockResolvedValueOnce(stream2); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'Call tool' }], + }); + + expect(mockConnection.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.not.arrayContaining([ + expect.objectContaining({ + optionId: ToolConfirmationOutcome.ProceedAlways, + }), + ]), + }), + ); + }); + it('should use filePath for ACP diff content in permission request', async () => { const confirmationDetails = { type: 'edit', diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index c36e214d27..072d91c20a 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -908,7 +908,7 @@ export class Session { const params: acp.RequestPermissionRequest = { sessionId: this.id, - options: toPermissionOptions(confirmationDetails), + options: toPermissionOptions(confirmationDetails, this.config), toolCall: { toolCallId: callId, status: 'pending', @@ -1004,6 +1004,7 @@ export class Session { callId, toolResult.llmContent, this.config.getActiveModel(), + this.config, ), resultDisplay: toolResult.returnDisplay, error: undefined, @@ -1017,6 +1018,7 @@ export class Session { callId, toolResult.llmContent, this.config.getActiveModel(), + this.config, ); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); @@ -1457,60 +1459,76 @@ const basicPermissionOptions = [ function toPermissionOptions( confirmation: ToolCallConfirmationDetails, + config: Config, ): acp.PermissionOption[] { - switch (confirmation.type) { - case 'edit': - return [ - { + const disableAlwaysAllow = config.getDisableAlwaysAllow(); + const options: acp.PermissionOption[] = []; + + if (!disableAlwaysAllow) { + switch (confirmation.type) { + case 'edit': + options.push({ optionId: ToolConfirmationOutcome.ProceedAlways, name: 'Allow All Edits', kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - case 'exec': - return [ - { + }); + break; + case 'exec': + options.push({ optionId: ToolConfirmationOutcome.ProceedAlways, name: `Always Allow ${confirmation.rootCommand}`, kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - case 'mcp': - return [ - { - optionId: ToolConfirmationOutcome.ProceedAlwaysServer, - name: `Always Allow ${confirmation.serverName}`, - kind: 'allow_always', - }, - { - optionId: ToolConfirmationOutcome.ProceedAlwaysTool, - name: `Always Allow ${confirmation.toolName}`, - kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - case 'info': - return [ - { + }); + break; + case 'mcp': + options.push( + { + optionId: ToolConfirmationOutcome.ProceedAlwaysServer, + name: `Always Allow ${confirmation.serverName}`, + kind: 'allow_always', + }, + { + optionId: ToolConfirmationOutcome.ProceedAlwaysTool, + name: `Always Allow ${confirmation.toolName}`, + kind: 'allow_always', + }, + ); + break; + case 'info': + options.push({ optionId: ToolConfirmationOutcome.ProceedAlways, name: `Always Allow`, kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; + }); + break; + case 'ask_user': + case 'exit_plan_mode': + // askuser and exit_plan_mode don't need "always allow" options + break; + default: + // No "always allow" options for other types + break; + } + } + + options.push(...basicPermissionOptions); + + // Exhaustive check + switch (confirmation.type) { + case 'edit': + case 'exec': + case 'mcp': + case 'info': case 'ask_user': - // askuser doesn't need "always allow" options since it's asking questions - return [...basicPermissionOptions]; case 'exit_plan_mode': - // exit_plan_mode doesn't need "always allow" options since it's a plan approval flow - return [...basicPermissionOptions]; + break; default: { const unreachable: never = confirmation; throw new Error(`Unexpected: ${unreachable}`); } } + + return options; } /** diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts index 9460af7ad1..f88aaac4f2 100644 --- a/packages/cli/src/acp/commands/memory.ts +++ b/packages/cli/src/acp/commands/memory.ts @@ -104,7 +104,10 @@ export class AddMemoryCommand implements Command { await context.sendMessage(`Saving memory via ${result.toolName}...`); await tool.buildAndExecute(result.toolArgs, signal, undefined, { - sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + shellExecutionConfig: { + sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + sandboxManager: context.config.sandboxManager, + }, }); await refreshMemory(context.config); return { diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index b0fd20d311..417e750651 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -137,6 +137,7 @@ describe('handleInstall', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], securityWarnings: [], discoveryErrors: [], @@ -379,6 +380,7 @@ describe('handleInstall', () => { mcps: [], hooks: [], skills: ['cool-skill'], + agents: ['cool-agent'], settings: [], securityWarnings: ['Security risk!'], discoveryErrors: ['Read error'], @@ -408,6 +410,10 @@ describe('handleInstall', () => { expect.stringContaining('cool-skill'), false, ); + expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( + expect.stringContaining('cool-agent'), + false, + ); expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect.stringContaining('Security Warnings:'), false, diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index eea7679c00..542d1240be 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -99,11 +99,15 @@ export async function handleInstall(args: InstallArgs) { if (hasDiscovery) { promptLines.push(chalk.bold('This folder contains:')); const groups = [ - { label: 'Commands', items: discoveryResults.commands }, - { label: 'MCP Servers', items: discoveryResults.mcps }, - { label: 'Hooks', items: discoveryResults.hooks }, - { label: 'Skills', items: discoveryResults.skills }, - { label: 'Setting overrides', items: discoveryResults.settings }, + { label: 'Commands', items: discoveryResults.commands ?? [] }, + { label: 'MCP Servers', items: discoveryResults.mcps ?? [] }, + { label: 'Hooks', items: discoveryResults.hooks ?? [] }, + { label: 'Skills', items: discoveryResults.skills ?? [] }, + { label: 'Agents', items: discoveryResults.agents ?? [] }, + { + label: 'Setting overrides', + items: discoveryResults.settings ?? [], + }, ].filter((g) => g.items.length > 0); for (const group of groups) { diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 334236fd85..a94d1f0a28 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -763,6 +763,48 @@ describe('loadCliConfig', () => { }); }); + it('should add IDE workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH to include directories', async () => { + vi.stubEnv( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + ['/project/folderA', '/project/folderB'].join(path.delimiter), + ); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + const dirs = config.getPendingIncludeDirectories(); + expect(dirs).toContain('/project/folderA'); + expect(dirs).toContain('/project/folderB'); + }); + + it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => { + const resolveToRealPathSpy = vi + .spyOn(ServerConfig, 'resolveToRealPath') + .mockImplementation((p) => { + if (p.toString().includes('restricted')) { + const err = new Error('EACCES: permission denied'); + (err as NodeJS.ErrnoException).code = 'EACCES'; + throw err; + } + return p.toString(); + }); + vi.stubEnv( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + ['/project/folderA', '/nonexistent/restricted/folder'].join( + path.delimiter, + ), + ); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + const dirs = config.getPendingIncludeDirectories(); + expect(dirs).toContain('/project/folderA'); + expect(dirs).not.toContain('/nonexistent/restricted/folder'); + + resolveToRealPathSpy.mockRestore(); + }); + it('should use default fileFilter options when unconfigured', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); @@ -798,6 +840,7 @@ describe('loadCliConfig', () => { describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { beforeEach(() => { vi.resetAllMocks(); + vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', ''); // Restore ExtensionManager mocks that were reset ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]); ExtensionManager.prototype.loadExtensions = vi @@ -809,12 +852,15 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); it('should pass extension context file paths to loadServerHierarchicalMemory', async () => { process.argv = ['node', 'script.js']; - const settings = createTestMergedSettings(); + const settings = createTestMergedSettings({ + experimental: { jitContext: false }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -865,6 +911,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { process.argv = ['node', 'script.js']; const includeDir = path.resolve(path.sep, 'path', 'to', 'include'); const settings = createTestMergedSettings({ + experimental: { jitContext: false }, context: { includeDirectories: [includeDir], loadMemoryFromIncludeDirectories: true, @@ -892,6 +939,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => { process.argv = ['node', 'script.js']; const settings = createTestMergedSettings({ + experimental: { jitContext: false }, context: { includeDirectories: ['/path/to/include'], loadMemoryFromIncludeDirectories: false, @@ -1773,7 +1821,7 @@ describe('loadCliConfig model selection', () => { }); it('always prefers model from argv', async () => { - process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash']; + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ @@ -1785,11 +1833,11 @@ describe('loadCliConfig model selection', () => { argv, ); - expect(config.getModel()).toBe('gemini-2.5-flash'); + expect(config.getModel()).toBe('gemini-2.5-flash-preview'); }); it('selects the model from argv if provided', async () => { - process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash']; + process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( createTestMergedSettings({ @@ -1799,7 +1847,7 @@ describe('loadCliConfig model selection', () => { argv, ); - expect(config.getModel()).toBe('gemini-2.5-flash'); + expect(config.getModel()).toBe('gemini-2.5-flash-preview'); }); it('selects the default auto model if provided via auto alias', async () => { @@ -3343,7 +3391,10 @@ describe('Policy Engine Integration in loadCliConfig', () => { expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ - policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'], + policyPaths: [ + path.normalize('/path/to/policy1.toml'), + path.normalize('/path/to/policy2.toml'), + ], }), expect.anything(), ); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e910d47546..3ab5ddb6ed 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -31,8 +31,6 @@ import { type HierarchicalMemory, coreEvents, GEMINI_MODEL_ALIAS_AUTO, - isValidModelOrAlias, - getValidModelsAndAliases, getAdminErrorMessage, isHeadlessMode, Config, @@ -246,10 +244,11 @@ export async function parseArguments( // When --resume passed without a value (`gemini --resume`): value = "" (string) // When --resume not passed at all: this `coerce` function is not called at all, and // `yargsInstance.argv.resume` is undefined. - if (value === '') { + const trimmed = value.trim(); + if (trimmed === '') { return RESUME_LATEST; } - return value; + return trimmed; }, }) .option('list-sessions', { @@ -431,8 +430,6 @@ export async function loadCliConfig( const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); - const loadedSettings = loadSettings(cwd); - if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; } @@ -476,10 +473,32 @@ export async function loadCliConfig( ...settings.context?.fileFiltering, }; + //changes the includeDirectories to be absolute paths based on the cwd, and also include any additional directories specified via CLI args const includeDirectories = (settings.context?.includeDirectories || []) .map(resolvePath) .concat((argv.includeDirectories || []).map(resolvePath)); + // When running inside VSCode with multiple workspace folders, + // automatically add the other folders as include directories + // so Gemini has context of all open folders, not just the cwd. + const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; + if (ideWorkspacePath) { + const realCwd = resolveToRealPath(cwd); + const ideFolders = ideWorkspacePath.split(path.delimiter).filter((p) => { + const trimmedPath = p.trim(); + if (!trimmedPath) return false; + try { + return resolveToRealPath(trimmedPath) !== realCwd; + } catch (e) { + debugLogger.debug( + `[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`, + ); + return false; + } + }); + includeDirectories.push(...ideFolders); + } + const extensionManager = new ExtensionManager({ settings, requestConsent: requestConsentNonInteractive, @@ -496,11 +515,12 @@ export async function loadCliConfig( .getExtensions() .find((ext) => ext.isActive && ext.plan?.directory)?.plan; - const experimentalJitContext = settings.experimental?.jitContext ?? false; + const experimentalJitContext = settings.experimental.jitContext; + + let extensionRegistryURI = + process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ?? + (trustedFolder ? settings.experimental?.extensionRegistryURI : undefined); - let extensionRegistryURI: string | undefined = trustedFolder - ? settings.experimental?.extensionRegistryURI - : undefined; if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) { extensionRegistryURI = resolveToRealPath( path.resolve(cwd, resolvePath(extensionRegistryURI)), @@ -651,8 +671,12 @@ export async function loadCliConfig( ...settings.mcp, allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed, }, - policyPaths: argv.policy ?? settings.policyPaths, - adminPolicyPaths: argv.adminPolicy ?? settings.adminPolicyPaths, + policyPaths: (argv.policy ?? settings.policyPaths)?.map((p) => + resolvePath(p), + ), + adminPolicyPaths: (argv.adminPolicy ?? settings.adminPolicyPaths)?.map( + (p) => resolvePath(p), + ), }; const { workspacePoliciesDir, policyUpdateConfirmationRequest } = @@ -673,23 +697,26 @@ export async function loadCliConfig( const specifiedModel = argv.model || process.env['GEMINI_MODEL'] || settings.model?.name; - // Validate the model if one was explicitly specified - if (specifiedModel && specifiedModel !== GEMINI_MODEL_ALIAS_AUTO) { - if (!isValidModelOrAlias(specifiedModel)) { - const validModels = getValidModelsAndAliases(); - - throw new FatalConfigError( - `Invalid model: "${specifiedModel}"\n\n` + - `Valid models and aliases:\n${validModels.map((m) => ` - ${m}`).join('\n')}\n\n` + - `Use /model to switch models interactively.`, - ); - } - } const resolvedModel = specifiedModel === GEMINI_MODEL_ALIAS_AUTO ? defaultModel : specifiedModel || defaultModel; const sandboxConfig = await loadSandboxConfig(settings, argv); + if (sandboxConfig) { + if (settings.tools.sandboxAllowedPaths?.length) { + sandboxConfig.allowedPaths = [ + ...new Set([ + ...sandboxConfig.allowedPaths, + ...settings.tools.sandboxAllowedPaths, + ]), + ]; + } + if (settings.tools.sandboxNetworkAccess !== undefined) { + sandboxConfig.networkAccess = + sandboxConfig.networkAccess || settings.tools.sandboxNetworkAccess; + } + } + const screenReader = argv.screenReader !== undefined ? argv.screenReader @@ -744,11 +771,14 @@ export async function loadCliConfig( clientVersion: await getVersion(), embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: sandboxConfig, + toolSandboxing: settings.security?.toolSandboxing ?? false, targetDir: cwd, includeDirectoryTree, includeDirectories, loadMemoryFromIncludeDirectories: settings.context?.loadMemoryFromIncludeDirectories || false, + discoveryMaxDirs: settings.context?.discoveryMaxDirs, + importFormat: settings.context?.importFormat, debugMode, question, @@ -784,6 +814,9 @@ export async function loadCliConfig( approvalMode, disableYoloMode: settings.security?.disableYoloMode || settings.admin?.secureModeEnabled, + disableAlwaysAllow: + settings.security?.disableAlwaysAllow || + settings.admin?.secureModeEnabled, showMemoryUsage: settings.ui?.showMemoryUsage || false, accessibility: { ...settings.ui?.accessibility, @@ -823,6 +856,7 @@ export async function loadCliConfig( disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, modelSteering: settings.experimental?.modelSteering, + topicUpdateNarration: settings.experimental?.topicUpdateNarration, toolOutputMasking: settings.experimental?.toolOutputMasking, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, @@ -857,6 +891,7 @@ export async function loadCliConfig( disableLLMCorrection: settings.tools?.disableLLMCorrection, rawOutput: argv.rawOutput, acceptRawOutputRisk: argv.acceptRawOutputRisk, + dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration, modelConfigServiceConfig: settings.modelConfigs, // TODO: loading of hooks based on workspace trust enableHooks: settings.hooksConfig.enabled, @@ -864,7 +899,7 @@ export async function loadCliConfig( hooks: settings.hooks || {}, disabledHooks: settings.hooksConfig?.disabled || [], projectHooks: projectHooks || {}, - onModelChange: (model: string) => saveModelChange(loadedSettings, model), + onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model), onReload: async () => { const refreshedSettings = loadSettings(cwd); return { diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index b1b21aab55..9358784a2f 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -20,7 +20,12 @@ import { import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionManager } from './extension-manager.js'; import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; -import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core'; +import { + GEMINI_DIR, + type Config, + tmpdir, + NoopSandboxManager, +} from '@google/gemini-cli-core'; import { createTestMergedSettings, SettingScope } from './settings.js'; describe('ExtensionManager theme loading', () => { @@ -117,6 +122,7 @@ describe('ExtensionManager theme loading', () => { terminalHeight: 24, showColor: false, pager: 'cat', + sandboxManager: new NoopSandboxManager(), sanitizationConfig: { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts index 13c1de15fa..67636d922e 100644 --- a/packages/cli/src/config/extension-manager.test.ts +++ b/packages/cli/src/config/extension-manager.test.ts @@ -18,9 +18,17 @@ import { loadTrustedFolders, isWorkspaceTrusted, } from './trustedFolders.js'; -import { getRealPath, type CustomTheme } from '@google/gemini-cli-core'; +import { + getRealPath, + type CustomTheme, + IntegrityDataStatus, +} from '@google/gemini-cli-core'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); @@ -36,6 +44,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, homedir: mockHomedir, + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), }; }); @@ -82,6 +93,7 @@ describe('ExtensionManager', () => { workspaceDir: tempWorkspaceDir, requestConsent: vi.fn().mockResolvedValue(true), requestSetting: null, + integrityManager: mockIntegrityManager, }); }); @@ -245,6 +257,7 @@ describe('ExtensionManager', () => { } as unknown as MergedSettings, requestConsent: () => Promise.resolve(true), requestSetting: null, + integrityManager: mockIntegrityManager, }); // Trust the workspace to allow installation @@ -290,6 +303,7 @@ describe('ExtensionManager', () => { settings, requestConsent: () => Promise.resolve(true), requestSetting: null, + integrityManager: mockIntegrityManager, }); const installMetadata = { @@ -324,6 +338,7 @@ describe('ExtensionManager', () => { settings, requestConsent: () => Promise.resolve(true), requestSetting: null, + integrityManager: mockIntegrityManager, }); const installMetadata = { @@ -353,6 +368,7 @@ describe('ExtensionManager', () => { settings: settingsOnlySymlink, requestConsent: () => Promise.resolve(true), requestSetting: null, + integrityManager: mockIntegrityManager, }); // This should FAIL because it checks the real path against the pattern @@ -507,6 +523,80 @@ describe('ExtensionManager', () => { }); }); + describe('extension integrity', () => { + it('should store integrity data during installation', async () => { + const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity'); + + const extDir = path.join(tempHomeDir, 'new-integrity-ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: 'integrity-ext', version: '1.0.0' }), + ); + + const installMetadata = { + source: extDir, + type: 'local' as const, + }; + + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension(installMetadata); + + expect(storeSpy).toHaveBeenCalledWith('integrity-ext', installMetadata); + }); + + it('should store integrity data during first update', async () => { + const storeSpy = vi.spyOn(extensionManager, 'storeExtensionIntegrity'); + const verifySpy = vi.spyOn(extensionManager, 'verifyExtensionIntegrity'); + + // Setup existing extension + const extName = 'update-integrity-ext'; + const extDir = path.join(userExtensionsDir, extName); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'gemini-extension.json'), + JSON.stringify({ name: extName, version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(extDir, 'metadata.json'), + JSON.stringify({ type: 'local', source: extDir }), + ); + + await extensionManager.loadExtensions(); + + // Ensure no integrity data exists for this extension + verifySpy.mockResolvedValueOnce(IntegrityDataStatus.MISSING); + + const initialStatus = await extensionManager.verifyExtensionIntegrity( + extName, + { type: 'local', source: extDir }, + ); + expect(initialStatus).toBe('missing'); + + // Create new version of the extension + const newSourceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'new-source-'), + ); + fs.writeFileSync( + path.join(newSourceDir, 'gemini-extension.json'), + JSON.stringify({ name: extName, version: '1.1.0' }), + ); + + const installMetadata = { + source: newSourceDir, + type: 'local' as const, + }; + + // Perform update and verify integrity was stored + await extensionManager.installOrUpdateExtension(installMetadata, { + name: extName, + version: '1.0.0', + }); + + expect(storeSpy).toHaveBeenCalledWith(extName, installMetadata); + }); + }); + describe('early theme registration', () => { it('should register themes with ThemeManager during loadExtensions for active extensions', async () => { createExtension({ @@ -547,4 +637,64 @@ describe('ExtensionManager', () => { ); }); }); + + describe('orphaned extension cleanup', () => { + it('should remove broken extension metadata on startup to allow re-installation', async () => { + const extName = 'orphaned-ext'; + const sourceDir = path.join(tempHomeDir, 'valid-source'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync( + path.join(sourceDir, 'gemini-extension.json'), + JSON.stringify({ name: extName, version: '1.0.0' }), + ); + + // Link an extension successfully. + await extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ + source: sourceDir, + type: 'link', + }); + + const destinationPath = path.join(userExtensionsDir, extName); + const metadataPath = path.join( + destinationPath, + '.gemini-extension-install.json', + ); + expect(fs.existsSync(metadataPath)).toBe(true); + + // Simulate metadata corruption (e.g., pointing to a non-existent source). + fs.writeFileSync( + metadataPath, + JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }), + ); + + // Simulate CLI startup. The manager should detect the broken link + // and proactively delete the orphaned metadata directory. + const newManager = new ExtensionManager({ + settings: createTestMergedSettings(), + workspaceDir: tempWorkspaceDir, + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + integrityManager: mockIntegrityManager, + }); + + await newManager.loadExtensions(); + + // Verify the extension failed to load and was proactively cleaned up. + expect(newManager.getExtensions().some((e) => e.name === extName)).toBe( + false, + ); + expect(fs.existsSync(destinationPath)).toBe(false); + + // Verify the system is self-healed and allows re-linking to the valid source. + await newManager.installOrUpdateExtension({ + source: sourceDir, + type: 'link', + }); + + expect(newManager.getExtensions().some((e) => e.name === extName)).toBe( + true, + ); + }); + }); }); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 68617bcbcd..2c46a845e6 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -41,6 +41,9 @@ import { loadSkillsFromDir, loadAgentsFromDirectory, homedir, + ExtensionIntegrityManager, + type IExtensionIntegrity, + type IntegrityDataStatus, type ExtensionEvents, type MCPServerConfig, type ExtensionInstallMetadata, @@ -89,6 +92,7 @@ interface ExtensionManagerParams { workspaceDir: string; eventEmitter?: EventEmitter; clientVersion?: string; + integrityManager?: IExtensionIntegrity; } /** @@ -98,6 +102,7 @@ interface ExtensionManagerParams { */ export class ExtensionManager extends ExtensionLoader { private extensionEnablementManager: ExtensionEnablementManager; + private integrityManager: IExtensionIntegrity; private settings: MergedSettings; private requestConsent: (consent: string) => Promise; private requestSetting: @@ -127,12 +132,28 @@ export class ExtensionManager extends ExtensionLoader { }); this.requestConsent = options.requestConsent; this.requestSetting = options.requestSetting ?? undefined; + this.integrityManager = + options.integrityManager ?? new ExtensionIntegrityManager(); } getEnablementManager(): ExtensionEnablementManager { return this.extensionEnablementManager; } + async verifyExtensionIntegrity( + extensionName: string, + metadata: ExtensionInstallMetadata | undefined, + ): Promise { + return this.integrityManager.verify(extensionName, metadata); + } + + async storeExtensionIntegrity( + extensionName: string, + metadata: ExtensionInstallMetadata, + ): Promise { + return this.integrityManager.store(extensionName, metadata); + } + setRequestConsent( requestConsent: (consent: string) => Promise, ): void { @@ -159,10 +180,7 @@ export class ExtensionManager extends ExtensionLoader { previousExtensionConfig?: ExtensionConfig, requestConsentOverride?: (consent: string) => Promise, ): Promise { - if ( - this.settings.security?.allowedExtensions && - this.settings.security?.allowedExtensions.length > 0 - ) { + if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { const extensionAllowed = this.settings.security?.allowedExtensions.some( (pattern) => { try { @@ -421,6 +439,12 @@ Would you like to attempt to install via "git clone" instead?`, ); await fs.promises.writeFile(metadataPath, metadataString); + // Establish trust at point of installation + await this.storeExtensionIntegrity( + newExtensionConfig.name, + installMetadata, + ); + // TODO: Gracefully handle this call failing, we should back up the old // extension prior to overwriting it and then restore and restart it. extension = await this.loadExtension(destinationPath); @@ -693,10 +717,7 @@ Would you like to attempt to install via "git clone" instead?`, const installMetadata = loadInstallMetadata(extensionDir); let effectiveExtensionPath = extensionDir; - if ( - this.settings.security?.allowedExtensions && - this.settings.security?.allowedExtensions.length > 0 - ) { + if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { if (!installMetadata?.source) { throw new Error( `Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`, @@ -898,9 +919,10 @@ Would you like to attempt to install via "git clone" instead?`, let skills = await loadSkillsFromDir( path.join(effectiveExtensionPath, 'skills'), ); - skills = skills.map((skill) => - recursivelyHydrateStrings(skill, hydrationContext), - ); + skills = skills.map((skill) => ({ + ...recursivelyHydrateStrings(skill, hydrationContext), + extensionName: config.name, + })); let rules: PolicyRule[] | undefined; let checkers: SafetyCheckerRule[] | undefined; @@ -923,9 +945,10 @@ Would you like to attempt to install via "git clone" instead?`, const agentLoadResult = await loadAgentsFromDirectory( path.join(effectiveExtensionPath, 'agents'), ); - agentLoadResult.agents = agentLoadResult.agents.map((agent) => - recursivelyHydrateStrings(agent, hydrationContext), - ); + agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({ + ...recursivelyHydrateStrings(agent, hydrationContext), + extensionName: config.name, + })); // Log errors but don't fail the entire extension load for (const error of agentLoadResult.errors) { @@ -959,11 +982,18 @@ Would you like to attempt to install via "git clone" instead?`, plan: config.plan, }; } catch (e) { - debugLogger.error( - `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( - e, - )}`, + const extName = path.basename(extensionDir); + debugLogger.warn( + `Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`, ); + try { + await fs.promises.rm(extensionDir, { recursive: true, force: true }); + } catch (rmError) { + debugLogger.error( + `Failed to remove broken extension directory ${extensionDir}:`, + rmError, + ); + } return null; } } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 38264b285a..fa957d8f7f 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -103,6 +103,10 @@ const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); const mockLogExtensionUpdateEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionDisable = vi.hoisted(() => vi.fn()); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -118,6 +122,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ExtensionInstallEvent: vi.fn(), ExtensionUninstallEvent: vi.fn(), ExtensionDisableEvent: vi.fn(), + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), KeychainTokenStorage: vi.fn().mockImplementation(() => ({ getSecret: vi.fn(), setSecret: vi.fn(), @@ -214,6 +221,7 @@ describe('extension tests', () => { requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings, + integrityManager: mockIntegrityManager, }); resetTrustedFoldersForTesting(); }); @@ -241,10 +249,8 @@ describe('extension tests', () => { expect(extensions[0].name).toBe('test-extension'); }); - it('should throw an error if a context file path is outside the extension directory', async () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'traversal-extension', @@ -654,10 +660,8 @@ name = "yolo-checker" expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}'); }); - it('should skip extensions with invalid JSON and log a warning', async () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + it('should remove an extension with invalid JSON config and log a warning', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Good extension createExtension({ @@ -678,17 +682,15 @@ name = "yolo-checker" expect(extensions[0].name).toBe('good-ext'); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( - `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, + `Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`, ), ); consoleSpy.mockRestore(); }); - it('should skip extensions with missing name and log a warning', async () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + it('should remove an extension with missing "name" in config and log a warning', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Good extension createExtension({ @@ -709,7 +711,7 @@ name = "yolo-checker" expect(extensions[0].name).toBe('good-ext'); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( - `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, + `Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, ), ); @@ -735,10 +737,8 @@ name = "yolo-checker" expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined(); }); - it('should throw an error for invalid extension names', async () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + it('should log a warning for invalid extension names during loading', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'bad_name', @@ -754,7 +754,7 @@ name = "yolo-checker" consoleSpy.mockRestore(); }); - it('should not load github extensions if blockGitExtensions is set', async () => { + it('should not load github extensions and log a warning if blockGitExtensions is set', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, @@ -774,6 +774,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: blockGitExtensionsSetting, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === 'my-ext'); @@ -807,6 +808,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: extensionAllowlistSetting, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -814,7 +816,7 @@ name = "yolo-checker" expect(extensions[0].name).toBe('my-ext'); }); - it('should not load disallowed extensions if the allowlist is set.', async () => { + it('should not load disallowed extensions and log a warning if the allowlist is set.', async () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, @@ -835,6 +837,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: extensionAllowlistSetting, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); const extension = extensions.find((e) => e.name === 'my-ext'); @@ -862,6 +865,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: loadedSettings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -885,6 +889,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: loadedSettings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -909,6 +914,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: loadedSettings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -1047,6 +1053,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -1082,6 +1089,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings, + integrityManager: mockIntegrityManager, }); const extensions = await extensionManager.loadExtensions(); @@ -1306,6 +1314,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: blockGitExtensionsSetting, + integrityManager: mockIntegrityManager, }); await extensionManager.loadExtensions(); await expect( @@ -1330,6 +1339,7 @@ name = "yolo-checker" requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, settings: allowedExtensionsSetting, + integrityManager: mockIntegrityManager, }); await extensionManager.loadExtensions(); await expect( @@ -1677,6 +1687,7 @@ ${INSTALL_WARNING_MESSAGE}`, requestConsent: mockRequestConsent, requestSetting: null, settings: loadSettings(tempWorkspaceDir).merged, + integrityManager: mockIntegrityManager, }); await extensionManager.loadExtensions(); diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index 7139c5d2c2..69339b4eeb 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -16,21 +16,14 @@ import { } from '@google/gemini-cli-core'; import { ExtensionManager } from '../extension-manager.js'; import { createTestMergedSettings } from '../settings.js'; +import { isWorkspaceTrusted } from '../trustedFolders.js'; // --- Mocks --- vi.mock('node:fs', async (importOriginal) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, - default: { - ...actual.default, - existsSync: vi.fn(), - statSync: vi.fn(), - lstatSync: vi.fn(), - realpathSync: vi.fn((p) => p), - }, existsSync: vi.fn(), statSync: vi.fn(), lstatSync: vi.fn(), @@ -38,6 +31,7 @@ vi.mock('node:fs', async (importOriginal) => { promises: { ...actual.promises, mkdir: vi.fn(), + readdir: vi.fn(), writeFile: vi.fn(), rm: vi.fn(), cp: vi.fn(), @@ -75,6 +69,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { Config: vi.fn().mockImplementation(() => ({ getEnableExtensionReloading: vi.fn().mockReturnValue(true), })), + KeychainService: class { + isAvailable = vi.fn().mockResolvedValue(true); + getPassword = vi.fn().mockResolvedValue('test-key'); + setPassword = vi.fn().mockResolvedValue(undefined); + }, + ExtensionIntegrityManager: class { + verify = vi.fn().mockResolvedValue('verified'); + store = vi.fn().mockResolvedValue(undefined); + }, + IntegrityDataStatus: { + VERIFIED: 'verified', + MISSING: 'missing', + INVALID: 'invalid', + }, }; }); @@ -134,13 +142,21 @@ describe('extensionUpdates', () => { vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined); vi.mocked(fs.promises.rm).mockResolvedValue(undefined); vi.mocked(fs.promises.cp).mockResolvedValue(undefined); + vi.mocked(fs.promises.readdir).mockResolvedValue([]); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + vi.mocked(getMissingSettings).mockResolvedValue([]); // Allow directories to exist by default to satisfy Config/WorkspaceContext checks vi.mocked(fs.existsSync).mockReturnValue(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(fs.lstatSync).mockReturnValue({ isDirectory: () => true } as any); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as fs.Stats); + vi.mocked(fs.lstatSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as fs.Stats); vi.mocked(fs.realpathSync).mockImplementation((p) => p as string); tempWorkspaceDir = '/mock/workspace'; @@ -202,11 +218,10 @@ describe('extensionUpdates', () => { ]); vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); // Mock loadExtension to return something so the method doesn't crash at the end - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({ + vi.spyOn(manager, 'loadExtension').mockResolvedValue({ name: 'test-ext', version: '1.1.0', - } as GeminiCLIExtension); + } as unknown as GeminiCLIExtension); // 4. Mock External Helpers // This is the key fix: we explicitly mock `getMissingSettings` to return @@ -235,5 +250,52 @@ describe('extensionUpdates', () => { ), ); }); + + it('should store integrity data after update', async () => { + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.1.0', + }; + + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + }; + + const installMetadata: ExtensionInstallMetadata = { + source: '/mock/source', + type: 'local', + }; + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: createTestMergedSettings(), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + }); + + await manager.loadExtensions(); + vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig); + vi.spyOn(manager, 'getExtensions').mockReturnValue([ + { + name: 'test-ext', + version: '1.0.0', + installMetadata, + path: '/mock/extensions/test-ext', + isActive: true, + } as unknown as GeminiCLIExtension, + ]); + vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); + vi.spyOn(manager, 'loadExtension').mockResolvedValue({ + name: 'test-ext', + version: '1.1.0', + } as unknown as GeminiCLIExtension); + + const storeSpy = vi.spyOn(manager, 'storeExtensionIntegrity'); + + await manager.installOrUpdateExtension(installMetadata, previousConfig); + + expect(storeSpy).toHaveBeenCalledWith('test-ext', installMetadata); + }); }); }); diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index 451c3b53da..a0a959bebd 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -15,13 +15,16 @@ import { type ExtensionUpdateStatus, } from '../../ui/state/extensions.js'; import { ExtensionStorage } from './storage.js'; -import { copyExtension, type ExtensionManager } from '../extension-manager.js'; +import { type ExtensionManager, copyExtension } from '../extension-manager.js'; import { checkForExtensionUpdate } from './github.js'; import { loadInstallMetadata } from '../extension.js'; import * as fs from 'node:fs'; -import type { GeminiCLIExtension } from '@google/gemini-cli-core'; +import { + type GeminiCLIExtension, + type ExtensionInstallMetadata, + IntegrityDataStatus, +} from '@google/gemini-cli-core'; -// Mock dependencies vi.mock('./storage.js', () => ({ ExtensionStorage: { createTmpDir: vi.fn(), @@ -64,8 +67,18 @@ describe('Extension Update Logic', () => { beforeEach(() => { vi.clearAllMocks(); mockExtensionManager = { - loadExtensionConfig: vi.fn(), - installOrUpdateExtension: vi.fn(), + loadExtensionConfig: vi.fn().mockResolvedValue({ + name: 'test-extension', + version: '1.0.0', + }), + installOrUpdateExtension: vi.fn().mockResolvedValue({ + ...mockExtension, + version: '1.1.0', + }), + verifyExtensionIntegrity: vi + .fn() + .mockResolvedValue(IntegrityDataStatus.VERIFIED), + storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined), } as unknown as ExtensionManager; mockDispatch = vi.fn(); @@ -92,7 +105,7 @@ describe('Extension Update Logic', () => { it('should throw error and set state to ERROR if install metadata type is unknown', async () => { vi.mocked(loadInstallMetadata).mockReturnValue({ type: undefined, - } as unknown as import('@google/gemini-cli-core').ExtensionInstallMetadata); + } as unknown as ExtensionInstallMetadata); await expect( updateExtension( @@ -295,6 +308,77 @@ describe('Extension Update Logic', () => { }); expect(fs.promises.rm).toHaveBeenCalled(); }); + + describe('Integrity Verification', () => { + it('should fail update with security alert if integrity is invalid', async () => { + vi.mocked( + mockExtensionManager.verifyExtensionIntegrity, + ).mockResolvedValue(IntegrityDataStatus.INVALID); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow( + 'Extension test-extension cannot be updated. Extension integrity cannot be verified.', + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.ERROR, + }, + }); + }); + + it('should establish trust on first update if integrity data is missing', async () => { + vi.mocked( + mockExtensionManager.verifyExtensionIntegrity, + ).mockResolvedValue(IntegrityDataStatus.MISSING); + + await updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ); + + // Verify updateExtension delegates to installOrUpdateExtension, + // which is responsible for establishing trust internally. + expect( + mockExtensionManager.installOrUpdateExtension, + ).toHaveBeenCalled(); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.UPDATED_NEEDS_RESTART, + }, + }); + }); + + it('should throw if integrity manager throws', async () => { + vi.mocked( + mockExtensionManager.verifyExtensionIntegrity, + ).mockRejectedValue(new Error('Verification failed')); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow( + 'Extension test-extension cannot be updated. Verification failed', + ); + }); + }); }); describe('updateAllUpdatableExtensions', () => { diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index 4a91907d8f..c4b7113530 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -15,6 +15,7 @@ import { debugLogger, getErrorMessage, type GeminiCLIExtension, + IntegrityDataStatus, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import { copyExtension, type ExtensionManager } from '../extension-manager.js'; @@ -51,6 +52,26 @@ export async function updateExtension( `Extension ${extension.name} cannot be updated, type is unknown.`, ); } + + try { + const status = await extensionManager.verifyExtensionIntegrity( + extension.name, + installMetadata, + ); + + if (status === IntegrityDataStatus.INVALID) { + throw new Error('Extension integrity cannot be verified'); + } + } catch (e) { + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, + }); + throw new Error( + `Extension ${extension.name} cannot be updated. ${getErrorMessage(e)}. To fix this, reinstall the extension.`, + ); + } + if (installMetadata?.type === 'link') { dispatchExtensionStateUpdate({ type: 'SET_STATE', diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 71d5f49e59..847b47bbe3 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -346,6 +346,12 @@ describe('Policy Engine Integration Tests', () => { expect( (await engine.check({ name: 'list_directory' }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'get_internal_docs' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'cli_help' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); // Other tools should be denied via catch all expect( diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 4bbd396fba..9837c2c355 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -63,6 +63,9 @@ export async function createPolicyEngineConfig( policyPaths: settings.policyPaths, adminPolicyPaths: settings.adminPolicyPaths, workspacePoliciesDir, + disableAlwaysAllow: + settings.security?.disableAlwaysAllow || + settings.admin?.secureModeEnabled, }; return createCorePolicyEngineConfig(policySettings, approvalMode); diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 3cdef142d7..1a047760d3 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -35,7 +35,9 @@ const VALID_SANDBOX_COMMANDS = [ function isSandboxCommand( value: string, ): value is Exclude { - return VALID_SANDBOX_COMMANDS.includes(value); + return (VALID_SANDBOX_COMMANDS as ReadonlyArray).includes( + value, + ); } function getSandboxCommand( diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index af143afcc0..06129a4760 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -524,16 +524,19 @@ describe('Settings Loading and Merging', () => { const userSettingsContent = { security: { disableYoloMode: false, + disableAlwaysAllow: false, }, }; const workspaceSettingsContent = { security: { disableYoloMode: false, // This should be ignored + disableAlwaysAllow: false, // This should be ignored }, }; const systemSettingsContent = { security: { disableYoloMode: true, + disableAlwaysAllow: true, }, }; @@ -551,6 +554,7 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used + expect(settings.merged.security?.disableAlwaysAllow).toBe(true); // System setting should be used }); it.each([ diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 53d75bd436..37ddf87642 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -400,12 +400,10 @@ describe('SettingsSchema', () => { expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('Experimental'); - expect(setting.default).toBe(false); + expect(setting.default).toBe(true); expect(setting.requiresRestart).toBe(true); expect(setting.showInDialog).toBe(false); - expect(setting.description).toBe( - 'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents', - ); + expect(setting.description).toBe('Enable local and remote subagents.'); }); it('should have skills setting enabled by default', () => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e69340c6f7..3cce8f8d3a 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1039,6 +1039,48 @@ const SETTINGS_SCHEMA = { 'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.', showInDialog: false, }, + modelDefinitions: { + type: 'object', + label: 'Model Definitions', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelDefinitions, + description: + 'Registry of model metadata, including tier, family, and features.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'ModelDefinition', + }, + }, + modelIdResolutions: { + type: 'object', + label: 'Model ID Resolutions', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelIdResolutions, + description: + 'Rules for resolving requested model names to concrete model IDs based on context.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'ModelResolution', + }, + }, + classifierIdResolutions: { + type: 'object', + label: 'Classifier ID Resolutions', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.classifierIdResolutions, + description: + 'Rules for resolving classifier tiers (flash, pro) to concrete model IDs.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'ModelResolution', + }, + }, }, }, @@ -1117,6 +1159,19 @@ const SETTINGS_SCHEMA = { description: 'Model override for the visual agent.', showInDialog: false, }, + allowedDomains: { + type: 'array', + label: 'Allowed Domains', + category: 'Advanced', + requiresRestart: true, + default: ['github.com', '*.google.com', 'localhost'] as string[], + description: oneLine` + A list of allowed domains for the browser agent + (e.g., ["github.com", "*.google.com"]). + `, + showInDialog: false, + items: { type: 'string' }, + }, disableUserInput: { type: 'boolean', label: 'Disable User Input', @@ -1287,7 +1342,7 @@ const SETTINGS_SCHEMA = { default: undefined as boolean | string | SandboxConfig | undefined, ref: 'BooleanOrStringOrObject', description: oneLine` - Sandbox execution environment. + Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). `, @@ -1529,6 +1584,16 @@ const SETTINGS_SCHEMA = { description: 'Security-related settings.', showInDialog: false, properties: { + toolSandboxing: { + type: 'boolean', + label: 'Tool Sandboxing', + category: 'Security', + requiresRestart: false, + default: false, + description: + 'Experimental tool-level sandboxing (implementation in progress).', + showInDialog: true, + }, disableYoloMode: { type: 'boolean', label: 'Disable YOLO Mode', @@ -1538,6 +1603,16 @@ const SETTINGS_SCHEMA = { description: 'Disable YOLO mode, even if enabled by a flag.', showInDialog: true, }, + disableAlwaysAllow: { + type: 'boolean', + label: 'Disable Always Allow', + category: 'Security', + requiresRestart: true, + default: false, + description: + 'Disable "Always allow" options in tool confirmation dialogs.', + showInDialog: true, + }, enablePermanentToolApproval: { type: 'boolean', label: 'Allow Permanent Tool Approval', @@ -1811,9 +1886,8 @@ const SETTINGS_SCHEMA = { label: 'Enable Agents', category: 'Experimental', requiresRestart: true, - default: false, - description: - 'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents', + default: true, + description: 'Enable local and remote subagents.', showInDialog: false, }, extensionManagement: { @@ -1868,7 +1942,7 @@ const SETTINGS_SCHEMA = { label: 'JIT Context Loading', category: 'Experimental', requiresRestart: true, - default: false, + default: true, description: 'Enable Just-In-Time (JIT) context loading.', showInDialog: false, }, @@ -1930,6 +2004,16 @@ const SETTINGS_SCHEMA = { 'Enable web fetch behavior that bypasses LLM summarization.', showInDialog: true, }, + dynamicModelConfiguration: { + type: 'boolean', + label: 'Dynamic Model Configuration', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.', + showInDialog: false, + }, gemmaModelRouter: { type: 'object', label: 'Gemma Model Router', @@ -1981,9 +2065,18 @@ const SETTINGS_SCHEMA = { }, }, }, + topicUpdateNarration: { + type: 'boolean', + label: 'Topic & Update Narration', + category: 'Experimental', + requiresRestart: false, + default: false, + description: + 'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.', + showInDialog: true, + }, }, }, - extensions: { type: 'object', label: 'Extensions', @@ -2264,7 +2357,8 @@ const SETTINGS_SCHEMA = { category: 'Admin', requiresRestart: false, default: false, - description: 'If true, disallows yolo mode from being used.', + description: + 'If true, disallows YOLO mode and "Always allow" options from being used.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, }, @@ -2746,6 +2840,53 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + ModelDefinition: { + type: 'object', + description: 'Model metadata registry entry.', + properties: { + displayName: { type: 'string' }, + tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] }, + family: { type: 'string' }, + isPreview: { type: 'boolean' }, + isVisible: { type: 'boolean' }, + dialogDescription: { type: 'string' }, + features: { + type: 'object', + properties: { + thinking: { type: 'boolean' }, + multimodalToolUse: { type: 'boolean' }, + }, + }, + }, + }, + ModelResolution: { + type: 'object', + description: 'Model resolution rule.', + properties: { + default: { type: 'string' }, + contexts: { + type: 'array', + items: { + type: 'object', + properties: { + condition: { + type: 'object', + properties: { + useGemini3_1: { type: 'boolean' }, + useCustomTools: { type: 'boolean' }, + hasAccessToPreview: { type: 'boolean' }, + requestedModels: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + target: { type: 'string' }, + }, + }, + }, + }, + }, }; export function getSettingsSchema(): SettingsSchemaType { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 04a370d7e9..4722bb73f3 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -647,7 +647,7 @@ export async function main() { process.exit(ExitCodes.FATAL_INPUT_ERROR); } - const prompt_id = Math.random().toString(16).slice(2); + const prompt_id = sessionId; logUserPrompt( config, new UserPromptEvent( diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx index a27cdbbb78..a6337ef29c 100644 --- a/packages/cli/src/interactiveCli.tsx +++ b/packages/cli/src/interactiveCli.tsx @@ -101,18 +101,8 @@ export async function startInteractiveUI( return ( - - + + diff --git a/packages/cli/src/services/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts index 15a2ebec18..51cc098536 100644 --- a/packages/cli/src/services/SkillCommandLoader.test.ts +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -122,4 +122,16 @@ describe('SkillCommandLoader', () => { const actionResult = (await commands[0].action!({} as any, '')) as any; expect(actionResult.toolArgs).toEqual({ name: 'my awesome skill' }); }); + + it('should propagate extensionName to the generated slash command', async () => { + const mockSkills = [ + { name: 'skill1', description: 'desc', extensionName: 'ext1' }, + ]; + mockSkillManager.getDisplayableSkills.mockReturnValue(mockSkills); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + + expect(commands[0].extensionName).toBe('ext1'); + }); }); diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts index 85f1884299..e264da2e31 100644 --- a/packages/cli/src/services/SkillCommandLoader.ts +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -41,6 +41,7 @@ export class SkillCommandLoader implements ICommandLoader { description: skill.description || `Activate the ${skill.name} skill`, kind: CommandKind.SKILL, autoExecute: true, + extensionName: skill.extensionName, action: async (_context, args) => ({ type: 'tool', toolName: ACTIVATE_SKILL_TOOL_NAME, diff --git a/packages/cli/src/services/SlashCommandConflictHandler.test.ts b/packages/cli/src/services/SlashCommandConflictHandler.test.ts index a828923fe5..5527188a04 100644 --- a/packages/cli/src/services/SlashCommandConflictHandler.test.ts +++ b/packages/cli/src/services/SlashCommandConflictHandler.test.ts @@ -172,4 +172,23 @@ describe('SlashCommandConflictHandler', () => { vi.advanceTimersByTime(600); expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); }); + + it('should display a descriptive message for a skill conflict', () => { + simulateEvent([ + { + name: 'chat', + renamedTo: 'google-workspace.chat', + loserExtensionName: 'google-workspace', + loserKind: CommandKind.SKILL, + winnerKind: CommandKind.BUILT_IN, + }, + ]); + + vi.advanceTimersByTime(600); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + "Extension 'google-workspace' skill '/chat' was renamed to '/google-workspace.chat' because it conflicts with built-in command.", + ); + }); }); diff --git a/packages/cli/src/services/SlashCommandConflictHandler.ts b/packages/cli/src/services/SlashCommandConflictHandler.ts index b51617840e..7da4e53842 100644 --- a/packages/cli/src/services/SlashCommandConflictHandler.ts +++ b/packages/cli/src/services/SlashCommandConflictHandler.ts @@ -154,6 +154,10 @@ export class SlashCommandConflictHandler { return extensionName ? `extension '${extensionName}' command` : 'extension command'; + case CommandKind.SKILL: + return extensionName + ? `extension '${extensionName}' skill` + : 'skill command'; case CommandKind.MCP_PROMPT: return mcpServerName ? `MCP server '${mcpServerName}' command` diff --git a/packages/cli/src/services/SlashCommandResolver.test.ts b/packages/cli/src/services/SlashCommandResolver.test.ts index e703028b3d..43d1c310a8 100644 --- a/packages/cli/src/services/SlashCommandResolver.test.ts +++ b/packages/cli/src/services/SlashCommandResolver.test.ts @@ -173,5 +173,30 @@ describe('SlashCommandResolver', () => { expect(finalCommands.find((c) => c.name === 'gcp.deploy1')).toBeDefined(); }); + + it('should prefix skills with extension name when they conflict with built-in', () => { + const builtin = createMockCommand('chat', CommandKind.BUILT_IN); + const skill = { + ...createMockCommand('chat', CommandKind.SKILL), + extensionName: 'google-workspace', + }; + + const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('chat'); + expect(names).toContain('google-workspace.chat'); + }); + + it('should NOT prefix skills with "skill" when extension name is missing', () => { + const builtin = createMockCommand('chat', CommandKind.BUILT_IN); + const skill = createMockCommand('chat', CommandKind.SKILL); + + const { finalCommands } = SlashCommandResolver.resolve([builtin, skill]); + + const names = finalCommands.map((c) => c.name); + expect(names).toContain('chat'); + expect(names).toContain('chat1'); + }); }); }); diff --git a/packages/cli/src/services/SlashCommandResolver.ts b/packages/cli/src/services/SlashCommandResolver.ts index d4e7efc7bb..4947e6545a 100644 --- a/packages/cli/src/services/SlashCommandResolver.ts +++ b/packages/cli/src/services/SlashCommandResolver.ts @@ -174,6 +174,7 @@ export class SlashCommandResolver { private static getPrefix(cmd: SlashCommand): string | undefined { switch (cmd.kind) { case CommandKind.EXTENSION_FILE: + case CommandKind.SKILL: return cmd.extensionName; case CommandKind.MCP_PROMPT: return cmd.mcpServerName; @@ -185,7 +186,6 @@ export class SlashCommandResolver { return undefined; } } - /** * Logs a conflict event. */ diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 0f6fb562a8..84010ab625 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -13,6 +13,7 @@ import { ApprovalMode, getShellConfiguration, PolicyDecision, + NoopSandboxManager, } from '@google/gemini-cli-core'; import { quote } from 'shell-quote'; import { createPartFromText } from '@google/genai'; @@ -77,7 +78,14 @@ describe('ShellProcessor', () => { getTargetDir: vi.fn().mockReturnValue('/test/dir'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getEnableInteractiveShell: vi.fn().mockReturnValue(false), - getShellExecutionConfig: vi.fn().mockReturnValue({}), + getShellExecutionConfig: vi.fn().mockReturnValue({ + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), getPolicyEngine: vi.fn().mockReturnValue({ check: mockPolicyEngineCheck, }), diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 6ee39c879c..39a896a3f8 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -30,6 +30,7 @@ import { IdeClient, debugLogger, CoreToolCallStatus, + IntegrityDataStatus, } from '@google/gemini-cli-core'; import { type MockShellCommand, @@ -118,6 +119,12 @@ class MockExtensionManager extends ExtensionLoader { getExtensions = vi.fn().mockReturnValue([]); setRequestConsent = vi.fn(); setRequestSetting = vi.fn(); + integrityManager = { + verifyExtensionIntegrity: vi + .fn() + .mockResolvedValue(IntegrityDataStatus.VERIFIED), + storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined), + }; } // Mock GeminiRespondingSpinner to disable animations (avoiding 'act()' warnings) without triggering screen reader mode. @@ -197,6 +204,7 @@ export class AppRig { enableEventDrivenScheduler: true, extensionLoader: new MockExtensionManager(), excludeTools: this.options.configOverrides?.excludeTools, + useAlternateBuffer: false, ...this.options.configOverrides, }; this.config = makeFakeConfig(configParams); @@ -268,19 +276,22 @@ export class AppRig { enabled: false, hasSeenNudge: true, }, + ui: { + useAlternateBuffer: false, + }, }, }); } private stubRefreshAuth() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + // eslint-disable-next-line @typescript-eslint/no-explicit-any const gcConfig = this.config as any; gcConfig.refreshAuth = async (authMethod: AuthType) => { gcConfig.modelAvailabilityService.reset(); const newContentGeneratorConfig = { authType: authMethod, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + proxy: gcConfig.getProxy(), apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key', }; @@ -403,7 +414,6 @@ export class AppRig { config: this.config!, settings: this.settings!, width: this.options.terminalWidth ?? 120, - useAlternateBuffer: false, uiState: { terminalHeight: this.options.terminalHeight ?? 40, }, @@ -449,7 +459,7 @@ export class AppRig { const actualToolName = toolName === '*' ? undefined : toolName; this.config .getPolicyEngine() - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + .removeRulesForTool(actualToolName as string, source); this.breakpointTools.delete(toolName); } @@ -617,7 +627,7 @@ export class AppRig { async addUserHint(hint: string) { if (!this.config) throw new Error('AppRig not initialized'); await act(async () => { - this.config!.userHintService.addUserHint(hint); + this.config!.injectionService.addInjection(hint, 'user_steering'); }); } @@ -722,7 +732,7 @@ export class AppRig { .getGeminiClient() ?.getChatRecordingService(); if (recordingService) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any (recordingService as any).conversationFile = null; } } @@ -742,7 +752,7 @@ export class AppRig { MockShellExecutionService.reset(); ideContextStore.clear(); // Forcefully clear IdeClient singleton promise - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any (IdeClient as any).instancePromise = null; vi.clearAllMocks(); diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 47e56e1a44..b153aaf85e 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -37,14 +37,14 @@ export const createMockCommandContext = ( }, services: { config: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + settings: { merged: defaultMergedSettings, setValue: vi.fn(), forScope: vi.fn().mockReturnValue({ settings: {} }), } as unknown as LoadedSettings, git: undefined as GitService | undefined, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + logger: { log: vi.fn(), logMessage: vi.fn(), @@ -53,7 +53,7 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + ui: { addItem: vi.fn(), clear: vi.fn(), @@ -72,7 +72,7 @@ export const createMockCommandContext = ( } as any, session: { sessionShellAllowlist: new Set(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stats: { sessionStartTime: new Date(), lastPromptTokenCount: 0, @@ -93,14 +93,12 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any const merge = (target: any, source: any): any => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const output = { ...target }; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const sourceValue = source[key]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const targetValue = output[key]; if ( @@ -108,11 +106,10 @@ export const createMockCommandContext = ( Object.prototype.toString.call(sourceValue) === '[object Object]' && Object.prototype.toString.call(targetValue) === '[object Object]' ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment output[key] = merge(targetValue, sourceValue); } else { // If not, we do a direct assignment. This preserves Date objects and others. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + output[key] = sourceValue; } } @@ -120,6 +117,5 @@ export const createMockCommandContext = ( return output; }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return merge(defaultMocks, overrides); }; diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index 170d009843..d4f11212e3 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -5,6 +5,7 @@ */ import { vi } from 'vitest'; +import { NoopSandboxManager } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; import { createTestMergedSettings, @@ -16,7 +17,6 @@ import { * Creates a mocked Config object with default values and allows overrides. */ export const createMockConfig = (overrides: Partial = {}): Config => - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ({ getSandbox: vi.fn(() => undefined), getQuestion: vi.fn(() => ''), @@ -78,6 +78,8 @@ export const createMockConfig = (overrides: Partial = {}): Config => getFileService: vi.fn().mockReturnValue({}), getGitService: vi.fn().mockResolvedValue({}), getUserMemory: vi.fn().mockReturnValue(''), + getSystemInstructionMemory: vi.fn().mockReturnValue(''), + getSessionMemory: vi.fn().mockReturnValue(''), getGeminiMdFilePaths: vi.fn().mockReturnValue([]), getShowMemoryUsage: vi.fn().mockReturnValue(false), getAccessibility: vi.fn().mockReturnValue({}), @@ -121,6 +123,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''), getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''), isInteractiveShellEnabled: vi.fn().mockReturnValue(false), + getDisableAlwaysAllow: vi.fn().mockReturnValue(false), isSkillsSupportEnabled: vi.fn().mockReturnValue(false), reloadSkills: vi.fn().mockResolvedValue(undefined), reloadAgents: vi.fn().mockResolvedValue(undefined), @@ -131,7 +134,14 @@ export const createMockConfig = (overrides: Partial = {}): Config => getRetryFetchErrors: vi.fn().mockReturnValue(true), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000), - getShellExecutionConfig: vi.fn().mockReturnValue({}), + getShellExecutionConfig: vi.fn().mockReturnValue({ + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), setShellExecutionConfig: vi.fn(), getEnableToolOutputTruncation: vi.fn().mockReturnValue(true), getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000), @@ -173,11 +183,9 @@ export function createMockSettings( overrides: Record = {}, ): LoadedSettings { const merged = createTestMergedSettings( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (overrides['merged'] as Partial) || {}, ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return { system: { settings: {} }, systemDefaults: { settings: {} }, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 74bac044c4..ede4fd6a5c 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -18,7 +18,7 @@ import type React from 'react'; import { act, useState } from 'react'; import os from 'node:os'; import path from 'node:path'; -import { LoadedSettings } from '../config/settings.js'; +import type { LoadedSettings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; @@ -416,11 +416,10 @@ export const render = ( stdout.clear(); act(() => { instance = inkRenderDirect(tree, { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion stdout: stdout as unknown as NodeJS.WriteStream, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stderr: stderr as unknown as NodeJS.WriteStream, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stdin: stdin as unknown as NodeJS.ReadStream, debug: false, exitOnCtrlC: false, @@ -499,7 +498,6 @@ const getMockConfigInternal = (): Config => { return mockConfigInternal; }; -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const configProxy = new Proxy({} as Config, { get(_target, prop) { if (prop === 'getTargetDir') { @@ -526,21 +524,13 @@ const configProxy = new Proxy({} as Config, { } const internal = getMockConfigInternal(); if (prop in internal) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return internal[prop as keyof typeof internal]; } throw new Error(`mockConfig does not have property ${String(prop)}`); }, }); -export const mockSettings = new LoadedSettings( - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - { path: '', settings: {}, originalSettings: {} }, - true, - [], -); +export const mockSettings = createMockSettings(); // A minimal mock UIState to satisfy the context provider. // Tests that need specific UIState values should provide their own. @@ -657,9 +647,8 @@ export const renderWithProviders = ( uiState: providedUiState, width, mouseEventsEnabled = false, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + config = configProxy as unknown as Config, - useAlternateBuffer = true, uiActions, persistentState, appState = mockAppState, @@ -670,7 +659,6 @@ export const renderWithProviders = ( width?: number; mouseEventsEnabled?: boolean; config?: Config; - useAlternateBuffer?: boolean; uiActions?: Partial; persistentState?: { get?: typeof persistentStateMock.get; @@ -685,20 +673,17 @@ export const renderWithProviders = ( button?: 0 | 1 | 2, ) => Promise; } => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { get(target, prop) { if (prop in target) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return target[prop as keyof typeof target]; } // For properties not in the base mock or provided state, // we'll check the original proxy to see if it's a defined but // unprovided property, and if not, throw. if (prop in baseMockUiState) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return baseMockUiState[prop as keyof typeof baseMockUiState]; } throw new Error(`mockUiState does not have property ${String(prop)}`); @@ -716,31 +701,8 @@ export const renderWithProviders = ( persistentStateMock.mockClear(); const terminalWidth = width ?? baseState.terminalWidth; - let finalSettings = settings; - if (useAlternateBuffer !== undefined) { - finalSettings = createMockSettings({ - ...settings.merged, - ui: { - ...settings.merged.ui, - useAlternateBuffer, - }, - }); - } - - // Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value, - // without replacing the entire config object and its other values. - let finalConfig = config; - if (useAlternateBuffer !== undefined) { - finalConfig = new Proxy(config, { - get(target, prop, receiver) { - if (prop === 'getUseAlternateBuffer') { - return () => useAlternateBuffer; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Reflect.get(target, prop, receiver); - }, - }); - } + const finalSettings = settings; + const finalConfig = config; const mainAreaWidth = terminalWidth; @@ -768,7 +730,7 @@ export const renderWithProviders = ( capturedOverflowState = undefined; capturedOverflowActions = undefined; - const renderResult = render( + const wrapWithProviders = (comp: React.ReactElement) => ( @@ -803,7 +765,7 @@ export const renderWithProviders = ( flexGrow={0} flexDirection="column" > - {component} + {comp} @@ -821,12 +783,16 @@ export const renderWithProviders = ( - , - terminalWidth, + ); + const renderResult = render(wrapWithProviders(component), terminalWidth); + return { ...renderResult, + rerender: (newComponent: React.ReactElement) => { + renderResult.rerender(wrapWithProviders(newComponent)); + }, capturedOverflowState, capturedOverflowActions, simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => @@ -847,9 +813,8 @@ export function renderHook( waitUntilReady: () => Promise; generateSvg: () => string; } { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + let currentProps = options?.initialProps as Props; function TestComponent({ @@ -884,7 +849,6 @@ export function renderHook( function rerender(props?: Props) { if (arguments.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion currentProps = props as Props; } act(() => { @@ -911,7 +875,6 @@ export function renderHookWithProviders( width?: number; mouseEventsEnabled?: boolean; config?: Config; - useAlternateBuffer?: boolean; } = {}, ): { result: { current: Result }; @@ -920,7 +883,6 @@ export function renderHookWithProviders( waitUntilReady: () => Promise; generateSvg: () => string; } { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; let setPropsFn: ((props: Props) => void) | undefined; @@ -942,7 +904,7 @@ export function renderHookWithProviders( act(() => { renderResult = renderWithProviders( - {/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */} + {} , options, @@ -952,7 +914,6 @@ export function renderHookWithProviders( function rerender(newProps?: Props) { act(() => { if (arguments.length > 0 && setPropsFn) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion setPropsFn(newProps as Props); } else if (forceUpdateFn) { forceUpdateFn(); diff --git a/packages/cli/src/test-utils/settings.ts b/packages/cli/src/test-utils/settings.ts index dd498b6625..ab2420849d 100644 --- a/packages/cli/src/test-utils/settings.ts +++ b/packages/cli/src/test-utils/settings.ts @@ -46,23 +46,22 @@ export const createMockSettings = ( workspace, isTrusted, errors, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + merged: mergedOverride, ...settingsOverrides } = overrides; const loaded = new LoadedSettings( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (system as any) || { path: '', settings: {}, originalSettings: {} }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (systemDefaults as any) || { path: '', settings: {}, originalSettings: {} }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (user as any) || { path: '', settings: settingsOverrides, originalSettings: settingsOverrides, }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (workspace as any) || { path: '', settings: {}, originalSettings: {} }, isTrusted ?? true, errors || [], @@ -76,7 +75,6 @@ export const createMockSettings = ( // Assign any function overrides (e.g., vi.fn() for methods) for (const key in overrides) { if (typeof overrides[key] === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment (loaded as any)[key] = overrides[key]; } } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index d96bfe3071..4e59ab854e 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest'; import type React from 'react'; import { renderWithProviders } from '../test-utils/render.js'; +import { createMockSettings } from '../test-utils/settings.js'; import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; import { App } from './App.js'; import { type UIState } from './contexts/UIStateContext.js'; @@ -97,7 +98,8 @@ describe('App', () => { , { uiState: mockUIState, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); await waitUntilReady(); @@ -118,7 +120,8 @@ describe('App', () => { , { uiState: quittingUIState, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); await waitUntilReady(); @@ -139,7 +142,8 @@ describe('App', () => { , { uiState: quittingUIState, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -159,6 +163,8 @@ describe('App', () => { , { uiState: dialogUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -185,6 +191,8 @@ describe('App', () => { , { uiState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -201,6 +209,8 @@ describe('App', () => { , { uiState: mockUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -219,6 +229,8 @@ describe('App', () => { , { uiState: mockUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -265,7 +277,7 @@ describe('App', () => { ], } as UIState; - const configWithExperiment = makeFakeConfig(); + const configWithExperiment = makeFakeConfig({ useAlternateBuffer: true }); vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); @@ -274,6 +286,7 @@ describe('App', () => { { uiState: stateWithConfirmingTool, config: configWithExperiment, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -293,6 +306,8 @@ describe('App', () => { , { uiState: mockUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -306,6 +321,8 @@ describe('App', () => { , { uiState: mockUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); @@ -322,6 +339,8 @@ describe('App', () => { , { uiState: dialogUIState, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 13550d3f42..3e420f141d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -95,7 +95,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); import ansiEscapes from 'ansi-escapes'; -import { mergeSettings, type LoadedSettings } from '../config/settings.js'; +import { type LoadedSettings } from '../config/settings.js'; +import { createMockSettings } from '../test-utils/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { StreamingState } from './types.js'; @@ -484,23 +485,18 @@ describe('AppContainer State Management', () => { ); // Mock LoadedSettings - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - mockSettings = { - merged: { - ...defaultMergedSettings, - hideBanner: false, - hideFooter: false, - hideTips: false, - showMemoryUsage: false, - theme: 'default', - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: false, - hideWindowTitle: false, - useAlternateBuffer: false, - }, + mockSettings = createMockSettings({ + hideBanner: false, + hideFooter: false, + hideTips: false, + showMemoryUsage: false, + theme: 'default', + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + useAlternateBuffer: false, }, - } as unknown as LoadedSettings; + }); // Mock InitializationResult mockInitResult = { @@ -1008,16 +1004,12 @@ 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, - showMemoryUsage: false, - }, - } as unknown as LoadedSettings; + const settingsAllHidden = createMockSettings({ + hideBanner: true, + hideFooter: true, + hideTips: true, + showMemoryUsage: false, + }); let unmount: () => void; await act(async () => { @@ -1029,16 +1021,9 @@ 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, - showMemoryUsage: true, - }, - } as unknown as LoadedSettings; + const settingsWithMemory = createMockSettings({ + showMemoryUsage: true, + }); let unmount: () => void; await act(async () => { @@ -1078,9 +1063,7 @@ describe('AppContainer State Management', () => { }); it('handles undefined settings gracefully', async () => { - const undefinedSettings = { - merged: mergeSettings({}, {}, {}, {}, true), - } as LoadedSettings; + const undefinedSettings = createMockSettings(); let unmount: () => void; await act(async () => { @@ -1498,18 +1481,12 @@ 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: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: false, - hideWindowTitle: false, - }, + const mockSettingsWithShowStatusFalse = createMockSettings({ + ui: { + showStatusInTitle: false, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state as Active mockedUseGeminiStream.mockReturnValue({ @@ -1537,17 +1514,12 @@ describe('AppContainer State Management', () => { it('should use legacy terminal title when dynamicWindowTitle is false', () => { // Arrange: Set up mock settings with dynamicWindowTitle disabled - const mockSettingsWithDynamicTitleFalse = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - dynamicWindowTitle: false, - hideWindowTitle: false, - }, + const mockSettingsWithDynamicTitleFalse = createMockSettings({ + ui: { + dynamicWindowTitle: false, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ @@ -1575,18 +1547,12 @@ 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: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: true, - }, + const mockSettingsWithHideTitleTrue = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: true, }, - } as unknown as LoadedSettings; + }); // Act: Render the container const { unmount } = renderAppContainer({ @@ -1604,18 +1570,12 @@ 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: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const thoughtSubject = 'Processing request'; @@ -1644,18 +1604,12 @@ 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: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state as Idle with no thought mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); @@ -1679,18 +1633,12 @@ describe('AppContainer State Management', () => { it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; @@ -1742,17 +1690,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty but not focused mockedUseGeminiStream.mockReturnValue({ @@ -1801,17 +1744,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty with redirection active mockedUseGeminiStream.mockReturnValue({ @@ -1871,17 +1809,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty with NO output since operation started (silent) mockedUseGeminiStream.mockReturnValue({ @@ -1921,17 +1854,12 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock an active shell pty but not focused let lastOutputTime = startTime + 1000; @@ -2005,18 +1933,12 @@ 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: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought with a short subject const shortTitle = 'Short'; @@ -2046,18 +1968,12 @@ 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: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - showStatusInTitle: true, - hideWindowTitle: false, - }, + const mockSettingsWithTitleEnabled = createMockSettings({ + ui: { + showStatusInTitle: true, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock the streaming state and thought const title = 'Test Title'; @@ -2085,17 +2001,12 @@ describe('AppContainer State Management', () => { it('should use CLI_TITLE environment variable when set', () => { // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) - const mockSettingsWithTitleDisabled = { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { - ...mockSettings.merged.ui, - showStatusInTitle: false, - hideWindowTitle: false, - }, + const mockSettingsWithTitleDisabled = createMockSettings({ + ui: { + showStatusInTitle: false, + hideWindowTitle: false, }, - } as unknown as LoadedSettings; + }); // Mock CLI_TITLE environment variable vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); @@ -2664,17 +2575,9 @@ describe('AppContainer State Management', () => { ); // Update settings for this test run - const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); - const testSettings = { - ...mockSettings, - merged: { - ...defaultMergedSettings, - ui: { - ...defaultMergedSettings.ui, - useAlternateBuffer: isAlternateMode, - }, - }, - } as unknown as LoadedSettings; + const testSettings = createMockSettings({ + ui: { useAlternateBuffer: isAlternateMode }, + }); function TestChild() { useKeypress(childHandler || (() => {}), { @@ -3384,13 +3287,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { unmount = renderAppContainer({ - settings: { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { ...mockSettings.merged.ui, useAlternateBuffer: false }, - }, - } as LoadedSettings, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }).unmount; }); @@ -3426,13 +3323,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { unmount = renderAppContainer({ - settings: { - ...mockSettings, - merged: { - ...mockSettings.merged, - ui: { ...mockSettings.merged.ui, useAlternateBuffer: true }, - }, - } as LoadedSettings, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }).unmount; }); @@ -3701,16 +3592,9 @@ describe('AppContainer State Management', () => { }); it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { - const alternateSettings = mergeSettings({}, {}, {}, {}, true); - const settingsWithAlternateBuffer = { - merged: { - ...alternateSettings, - ui: { - ...alternateSettings.ui, - useAlternateBuffer: true, - }, - }, - } as unknown as LoadedSettings; + const settingsWithAlternateBuffer = createMockSettings({ + ui: { useAlternateBuffer: true }, + }); vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0bfdeba120..b2402f9fe9 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -85,6 +85,7 @@ import { buildUserSteeringHintPrompt, logBillingEvent, ApiKeyUpdatedEvent, + type InjectionSource, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -1089,13 +1090,16 @@ Logging in with Google... Restarting Gemini CLI to continue. }, []); useEffect(() => { - const hintListener = (hint: string) => { - pendingHintsRef.current.push(hint); + const hintListener = (text: string, source: InjectionSource) => { + if (source !== 'user_steering') { + return; + } + pendingHintsRef.current.push(text); setPendingHintCount((prev) => prev + 1); }; - config.userHintService.onUserHint(hintListener); + config.injectionService.onInjection(hintListener); return () => { - config.userHintService.offUserHint(hintListener); + config.injectionService.offInjection(hintListener); }; }, [config]); @@ -1259,7 +1263,7 @@ Logging in with Google... Restarting Gemini CLI to continue. if (!trimmed) { return; } - config.userHintService.addUserHint(trimmed); + config.injectionService.addInjection(trimmed, 'user_steering'); // Render hints with a distinct style. historyManager.addItem({ type: 'hint', @@ -1425,6 +1429,7 @@ Logging in with Google... Restarting Gemini CLI to continue. pager: settings.merged.tools.shell.pager, showColor: settings.merged.tools.shell.showColor, sanitizationConfig: config.sanitizationConfig, + sandboxManager: config.sandboxManager, }); const { isFocused, hasReceivedFocusEvent } = useFocus(); @@ -1672,11 +1677,6 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleGlobalKeypress = useCallback( (key: Key): boolean => { - // Debug log keystrokes if enabled - if (settings.merged.general.debugKeystrokeLogging) { - debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); - } - if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } @@ -1855,7 +1855,6 @@ Logging in with Google... Restarting Gemini CLI to continue. activePtyId, handleSuspend, embeddedShellFocused, - settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, tabFocusTimeoutRef, diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx index 52d00550ea..1b30e0e0b2 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -5,10 +5,9 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import { render } from '../test-utils/render.js'; +import { renderWithProviders } from '../test-utils/render.js'; import { act } from 'react'; import { IdeIntegrationNudge } from './IdeIntegrationNudge.js'; -import { KeypressProvider } from './contexts/KeypressContext.js'; import { debugLogger } from '@google/gemini-cli-core'; // Mock debugLogger @@ -54,10 +53,8 @@ describe('IdeIntegrationNudge', () => { }); it('renders correctly with default options', async () => { - const { lastFrame, waitUntilReady, unmount } = render( - - - , + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); const frame = lastFrame(); @@ -71,10 +68,8 @@ describe('IdeIntegrationNudge', () => { it('handles "Yes" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); @@ -94,10 +89,8 @@ describe('IdeIntegrationNudge', () => { it('handles "No" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); @@ -122,10 +115,8 @@ describe('IdeIntegrationNudge', () => { it('handles "Dismiss" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); @@ -155,10 +146,8 @@ describe('IdeIntegrationNudge', () => { it('handles Escape key press', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = render( - - - , + const { stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); @@ -184,10 +173,8 @@ describe('IdeIntegrationNudge', () => { vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp'); const onComplete = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = render( - - - , + const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( + , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 96c61fe8bd..0072bebf27 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -51,7 +51,7 @@ describe('clearCommand', () => { fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), }), - userHintService: { + injectionService: { clear: mockHintClear, }, }, diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 6d3b14e179..05eb96193f 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -30,7 +30,7 @@ export const clearCommand: SlashCommand = { } // Reset user steering hints - config?.userHintService.clear(); + config?.injectionService.clear(); // Start a new conversation recording with a new session ID // We MUST do this before calling resetChat() so the new ChatRecordingService diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx index 52cda094e0..2e5b6ecdb2 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -4,21 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { AgentConfigDialog } from './AgentConfigDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import type { AgentDefinition } from '@google/gemini-cli-core'; -vi.mock('../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - mainAreaWidth: 100, - }), -})); - enum TerminalKeys { ENTER = '\u000D', TAB = '\t', @@ -122,17 +115,16 @@ describe('AgentConfigDialog', () => { settings: LoadedSettings, definition: AgentDefinition = createMockAgentDefinition(), ) => { - const result = render( - - - , + const result = renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, ); await result.waitUntilReady(); return result; @@ -331,18 +323,17 @@ describe('AgentConfigDialog', () => { const settings = createMockSettings(); // Agent config has about 6 base items + 2 per tool // Render with very small height (20) - const { lastFrame, unmount } = render( - - - , + const { lastFrame, unmount } = renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, ); await waitFor(() => expect(lastFrame()).toContain('Configure: Test Agent'), diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 0857306ea8..67289769be 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -7,6 +7,8 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { AskUserDialog } from './AskUserDialog.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; @@ -87,6 +89,31 @@ describe('AskUserDialog', () => { writeKey(stdin, '\r'); // Toggle TS writeKey(stdin, '\x1b[B'); // Down writeKey(stdin, '\r'); // Toggle ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\x1b[B'); // Down to Other + writeKey(stdin, '\x1b[B'); // Down to Done + writeKey(stdin, '\r'); // Done + }, + expectedSubmit: { '0': 'TypeScript, ESLint' }, + }, + { + name: 'All of the above', + questions: [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[], + actions: (stdin: { write: (data: string) => void }) => { + writeKey(stdin, '\x1b[B'); // Down to ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\r'); // Toggle All of the above writeKey(stdin, '\x1b[B'); // Down to Other writeKey(stdin, '\x1b[B'); // Down to Done writeKey(stdin, '\r'); // Done @@ -131,6 +158,42 @@ describe('AskUserDialog', () => { }); }); + it('verifies "All of the above" visual state with snapshot', async () => { + const questions = [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[]; + + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 120 }, + ); + + // Navigate to "All of the above" and toggle it + writeKey(stdin, '\x1b[B'); // Down to ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\r'); // Toggle All of the above + + await waitFor(async () => { + await waitUntilReady(); + // Verify visual state (checkmarks on all options) + expect(lastFrame()).toMatchSnapshot(); + }); + }); + it('handles custom option in single select with inline typing', async () => { const onSubmit = vi.fn(); const { stdin, lastFrame, waitUntilReady } = renderWithProviders( @@ -252,7 +315,10 @@ describe('AskUserDialog', () => { width={80} availableHeight={10} // Small height to force scrolling />, - { useAlternateBuffer }, + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ ui: { useAlternateBuffer } }), + }, ); await waitFor(async () => { @@ -1230,7 +1296,10 @@ describe('AskUserDialog', () => { width={80} /> , - { useAlternateBuffer: false }, + { + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }, ); // With height 5 and alternate buffer disabled, it should show scroll arrows (▲) @@ -1266,7 +1335,10 @@ describe('AskUserDialog', () => { width={40} // Small width to force wrapping /> , - { useAlternateBuffer: true }, + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }, ); // Should NOT contain the truncation message diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index eec633b7de..b1d23885e6 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -395,7 +395,7 @@ interface OptionItem { key: string; label: string; description: string; - type: 'option' | 'other' | 'done'; + type: 'option' | 'other' | 'done' | 'all'; index: number; } @@ -407,6 +407,7 @@ interface ChoiceQuestionState { type ChoiceQuestionAction = | { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } } + | { type: 'TOGGLE_ALL'; payload: { totalOptions: number } } | { type: 'SET_CUSTOM_SELECTED'; payload: { selected: boolean; multiSelect: boolean }; @@ -419,6 +420,25 @@ function choiceQuestionReducer( action: ChoiceQuestionAction, ): ChoiceQuestionState { switch (action.type) { + case 'TOGGLE_ALL': { + const { totalOptions } = action.payload; + const allSelected = state.selectedIndices.size === totalOptions; + if (allSelected) { + return { + ...state, + selectedIndices: new Set(), + }; + } else { + const newIndices = new Set(); + for (let i = 0; i < totalOptions; i++) { + newIndices.add(i); + } + return { + ...state, + selectedIndices: newIndices, + }; + } + } case 'TOGGLE_INDEX': { const { index, multiSelect } = action.payload; const newIndices = new Set(multiSelect ? state.selectedIndices : []); @@ -703,6 +723,18 @@ const ChoiceQuestionView: React.FC = ({ }, ); + // Add 'All of the above' for multi-select + if (question.multiSelect && questionOptions.length > 1) { + const allItem: OptionItem = { + key: 'all', + label: 'All of the above', + description: 'Select all options', + type: 'all', + index: list.length, + }; + list.push({ key: 'all', value: allItem }); + } + // Only add custom option for choice type, not yesno if (question.type !== 'yesno') { const otherItem: OptionItem = { @@ -755,6 +787,11 @@ const ChoiceQuestionView: React.FC = ({ type: 'TOGGLE_CUSTOM_SELECTED', payload: { multiSelect: true }, }); + } else if (itemValue.type === 'all') { + dispatch({ + type: 'TOGGLE_ALL', + payload: { totalOptions: questionOptions.length }, + }); } else if (itemValue.type === 'done') { // Done just triggers navigation, selections already saved via useEffect onAnswer( @@ -783,6 +820,7 @@ const ChoiceQuestionView: React.FC = ({ }, [ question.multiSelect, + questionOptions.length, selectedIndices, isCustomOptionSelected, customOptionText, @@ -857,11 +895,16 @@ const ChoiceQuestionView: React.FC = ({ renderItem={(item, context) => { const optionItem = item.value; const isChecked = - selectedIndices.has(optionItem.index) || - (optionItem.type === 'other' && isCustomOptionSelected); + (optionItem.type === 'option' && + selectedIndices.has(optionItem.index)) || + (optionItem.type === 'other' && isCustomOptionSelected) || + (optionItem.type === 'all' && + selectedIndices.size === questionOptions.length); const showCheck = question.multiSelect && - (optionItem.type === 'option' || optionItem.type === 'other'); + (optionItem.type === 'option' || + optionItem.type === 'other' || + optionItem.type === 'all'); // Render inline text input for custom option if (optionItem.type === 'other') { diff --git a/packages/cli/src/ui/components/Banner.test.tsx b/packages/cli/src/ui/components/Banner.test.tsx index 46c47b8a71..00a2bf609f 100644 --- a/packages/cli/src/ui/components/Banner.test.tsx +++ b/packages/cli/src/ui/components/Banner.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { Banner } from './Banner.js'; import { describe, it, expect } from 'vitest'; @@ -12,22 +12,23 @@ describe('Banner', () => { it.each([ ['warning mode', true, 'Warning Message'], ['info mode', false, 'Info Message'], + ['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'], ])('renders in %s', async (_, isWarning, text) => { - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('handles newlines in text', async () => { const text = 'Line 1\\nLine 2'; - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); }); diff --git a/packages/cli/src/ui/components/Banner.tsx b/packages/cli/src/ui/components/Banner.tsx index 99f573a68e..3f9777aa45 100644 --- a/packages/cli/src/ui/components/Banner.tsx +++ b/packages/cli/src/ui/components/Banner.tsx @@ -14,20 +14,21 @@ export function getFormattedBannerContent( isWarning: boolean, subsequentLineColor: string, ): ReactNode { - if (isWarning) { - return ( - {rawText.replace(/\\n/g, '\n')} - ); - } - const text = rawText.replace(/\\n/g, '\n'); const lines = text.split('\n'); return lines.map((line, index) => { if (index === 0) { + if (isWarning) { + return ( + + {line} + + ); + } return ( - {line} + {line} ); } diff --git a/packages/cli/src/ui/components/ChecklistItem.test.tsx b/packages/cli/src/ui/components/ChecklistItem.test.tsx index 0f6c0eb0b0..4176f7914b 100644 --- a/packages/cli/src/ui/components/ChecklistItem.test.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx @@ -15,6 +15,7 @@ describe('', () => { { status: 'in_progress', label: 'Doing this' }, { status: 'completed', label: 'Done this' }, { status: 'cancelled', label: 'Skipped this' }, + { status: 'blocked', label: 'Blocked this' }, ] as ChecklistItemData[])('renders %s item correctly', async (item) => { const { lastFrame, waitUntilReady } = render(); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/ChecklistItem.tsx b/packages/cli/src/ui/components/ChecklistItem.tsx index 6e08e0af6b..065c79d516 100644 --- a/packages/cli/src/ui/components/ChecklistItem.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.tsx @@ -13,7 +13,8 @@ export type ChecklistStatus = | 'pending' | 'in_progress' | 'completed' - | 'cancelled'; + | 'cancelled' + | 'blocked'; export interface ChecklistItemData { status: ChecklistStatus; @@ -48,6 +49,12 @@ const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({ ✗ ); + case 'blocked': + return ( + + ⛔ + + ); default: checkExhaustive(status); } @@ -70,6 +77,7 @@ export const ChecklistItem: React.FC = ({ return theme.text.accent; case 'completed': case 'cancelled': + case 'blocked': return theme.text.secondary; case 'pending': return theme.text.primary; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 84f8d15a06..e0919947fb 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -408,7 +408,7 @@ describe('Composer', () => { thought: { subject: 'Hidden', description: 'Should not show' }, }); const settings = createMockSettings({ - merged: { ui: { loadingPhrases: 'off' } }, + ui: { loadingPhrases: 'off' }, }); const { lastFrame } = await renderComposer(uiState, settings); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index 65d54e50d6..b6fd50b33f 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -38,9 +38,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={false} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); @@ -64,9 +62,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={true} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); @@ -89,9 +85,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={true} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'low' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'low' } }), }, ); await waitUntilReady(); @@ -112,9 +106,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={true} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); @@ -135,9 +127,7 @@ describe('DetailedMessagesDisplay', () => { hasFocus={false} />, { - settings: createMockSettings({ - merged: { ui: { errorVerbosity: 'full' } }, - }), + settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx index 6ebe22d982..d3b285c3a4 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -4,11 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SettingScope, type LoadedSettings } from '../../config/settings.js'; -import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; import { waitFor } from '../../test-utils/async.js'; import { debugLogger } from '@google/gemini-cli-core'; @@ -52,8 +51,8 @@ describe('EditorSettingsDialog', () => { vi.clearAllMocks(); }); - const renderWithProvider = (ui: React.ReactNode) => - render({ui}); + const renderWithProvider = (ui: React.ReactElement) => + renderWithProviders(ui); it('renders correctly', async () => { const { lastFrame, waitUntilReady } = renderWithProvider( diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 33daca1e33..231d5f102f 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; import { waitFor } from '../../test-utils/async.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -138,8 +139,9 @@ Implement a comprehensive authentication system with multiple providers. vi.restoreAllMocks(); }); - const renderDialog = (options?: { useAlternateBuffer?: boolean }) => - renderWithProviders( + const renderDialog = (options?: { useAlternateBuffer?: boolean }) => { + const useAlternateBuffer = options?.useAlternateBuffer ?? true; + return renderWithProviders( options?.useAlternateBuffer ?? true, + getUseAlternateBuffer: () => useAlternateBuffer, } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ ui: { useAlternateBuffer } }), }, ); + }; describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])( 'useAlternateBuffer: $useAlternateBuffer', @@ -429,7 +433,6 @@ Implement a comprehensive authentication system with multiple providers. /> , { - useAlternateBuffer, config: { getTargetDir: () => mockTargetDir, getIdeMode: () => false, @@ -443,6 +446,9 @@ Implement a comprehensive authentication system with multiple providers. }), getUseAlternateBuffer: () => useAlternateBuffer ?? true, } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ + ui: { useAlternateBuffer: useAlternateBuffer ?? true }, + }), }, ); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 012b2aab2f..9ad4fac02d 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -5,11 +5,12 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig, ExitCodes } from '@google/gemini-cli-core'; import { waitFor } from '../../test-utils/async.js'; import { act } from 'react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; -import { ExitCodes } from '@google/gemini-cli-core'; import * as processUtils from '../../utils/processUtils.js'; vi.mock('../../utils/processUtils.js', () => ({ @@ -66,6 +67,7 @@ describe('FolderTrustDialog', () => { mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`), hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`), skills: Array.from({ length: 10 }, (_, i) => `skill${i}`), + agents: [], settings: Array.from({ length: 10 }, (_, i) => `setting${i}`), discoveryErrors: [], securityWarnings: [], @@ -77,7 +79,8 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 24 }, }, ); @@ -95,6 +98,7 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: [], @@ -106,7 +110,8 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 14 }, }, ); @@ -125,6 +130,7 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: [], @@ -136,7 +142,8 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: true, terminalHeight: 10 }, }, ); @@ -152,6 +159,7 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: [], @@ -164,7 +172,8 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), // Initially constrained uiState: { constrainHeight: true, terminalHeight: 24 }, }, @@ -190,7 +199,8 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: false, + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), uiState: { constrainHeight: false, terminalHeight: 24 }, }, ); @@ -332,6 +342,7 @@ describe('FolderTrustDialog', () => { mcps: ['mcp1'], hooks: ['hook1'], skills: ['skill1'], + agents: ['agent1'], settings: ['general', 'ui'], discoveryErrors: [], securityWarnings: [], @@ -355,6 +366,8 @@ describe('FolderTrustDialog', () => { expect(lastFrame()).toContain('- hook1'); expect(lastFrame()).toContain('• Skills (1):'); expect(lastFrame()).toContain('- skill1'); + expect(lastFrame()).toContain('• Agents (1):'); + expect(lastFrame()).toContain('- agent1'); expect(lastFrame()).toContain('• Setting overrides (2):'); expect(lastFrame()).toContain('- general'); expect(lastFrame()).toContain('- ui'); @@ -367,6 +380,7 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: ['Dangerous setting detected!'], @@ -390,6 +404,7 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: ['Failed to load custom commands'], securityWarnings: [], @@ -413,6 +428,7 @@ describe('FolderTrustDialog', () => { mcps: [], hooks: [], skills: [], + agents: [], settings: [], discoveryErrors: [], securityWarnings: [], @@ -424,7 +440,8 @@ describe('FolderTrustDialog', () => { />, { width: 80, - useAlternateBuffer: true, + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), uiState: { constrainHeight: false, terminalHeight: 15 }, }, ); @@ -446,6 +463,7 @@ describe('FolderTrustDialog', () => { mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`], hooks: [`${ansiRed}hook-with-ansi${ansiReset}`], skills: [`${ansiRed}skill-with-ansi${ansiReset}`], + agents: [], settings: [`${ansiRed}setting-with-ansi${ansiReset}`], discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`], securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 6c1c0d9e8c..5f226b7d15 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -135,6 +135,7 @@ export const FolderTrustDialog: React.FC = ({ { label: 'MCP Servers', items: discoveryResults?.mcps ?? [] }, { label: 'Hooks', items: discoveryResults?.hooks ?? [] }, { label: 'Skills', items: discoveryResults?.skills ?? [] }, + { label: 'Agents', items: discoveryResults?.agents ?? [] }, { label: 'Setting overrides', items: discoveryResults?.settings ?? [] }, ].filter((g) => g.items.length > 0); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index ab487a440f..84782b2513 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -673,9 +673,7 @@ describe('