diff --git a/.gemini/commands/promote-behavioral-eval.toml b/.gemini/commands/promote-behavioral-eval.toml new file mode 100644 index 0000000000..9893e9b02b --- /dev/null +++ b/.gemini/commands/promote-behavioral-eval.toml @@ -0,0 +1,29 @@ +description = "Promote behavioral evals that have a 100% success rate over the last 7 nightly runs." +prompt = """ +You are an expert at analyzing and promoting behavioral evaluations. + +1. **Investigate**: + - Use 'gh' cli to fetch the results from the most recent run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml. + - DO NOT push any changes or start any runs. The rest of your evaluation will be local. + - Evals are in evals/ directory and are documented by evals/README.md. + - Identify tests that have passed 100% of the time for ALL enabled models across the past 7 runs in a row. + - NOTE: the results summary from the most recent run contains the last 7 runs test results. 100% means the test passed 3/3 times for that model and run. + - If a test meets this criteria, it is a candidate for promotion. + +2. **Promote**: + - For each candidate test, locate the test file in the evals/ directory. + - Promote the test according to the project's standard promotion process (e.g., moving it to a stable suite, updating its tags, or removing skip/flaky annotations). + - Ensure you follow any guidelines in evals/README.md for stable tests. + - Your **final** change should be **minimal and targeted** to just promoting the test status. + +3. **Verify**: + - Run the promoted tests locally to validate that they still execute correctly. Be sure to run vitest in non-interactive mode. + - Check that the test is now part of the expected standard or stable test suites. + +4. **Report**: + - Provide a summary of the tests that were promoted. + - Include the success rate evidence (7/7 runs passed for all models) for each promoted test. + - If no tests met the criteria for promotion, clearly state that and summarize the closest candidates. + +{{args}} +""" diff --git a/.gemini/config.yaml b/.gemini/config.yaml index cbfb0c8059..b9543f0b7d 100644 --- a/.gemini/config.yaml +++ b/.gemini/config.yaml @@ -9,4 +9,5 @@ code_review: help: false summary: true code_review: true + include_drafts: false ignore_patterns: [] diff --git a/.github/actions/create-pull-request/action.yml b/.github/actions/create-pull-request/action.yml index 6a6b6dbf03..fa38bd58ab 100644 --- a/.github/actions/create-pull-request/action.yml +++ b/.github/actions/create-pull-request/action.yml @@ -39,18 +39,22 @@ runs: if: "inputs.dry-run != 'true'" env: GH_TOKEN: '${{ inputs.github-token }}' + INPUTS_BRANCH_NAME: '${{ inputs.branch-name }}' + INPUTS_PR_TITLE: '${{ inputs.pr-title }}' + INPUTS_PR_BODY: '${{ inputs.pr-body }}' + INPUTS_BASE_BRANCH: '${{ inputs.base-branch }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | set -e - if ! git ls-remote --exit-code --heads origin "${{ inputs.branch-name }}"; then - echo "::error::Branch '${{ inputs.branch-name }}' does not exist on the remote repository." + if ! git ls-remote --exit-code --heads origin "${INPUTS_BRANCH_NAME}"; then + echo "::error::Branch '${INPUTS_BRANCH_NAME}' does not exist on the remote repository." exit 1 fi PR_URL=$(gh pr create \ - --title "${{ inputs.pr-title }}" \ - --body "${{ inputs.pr-body }}" \ - --base "${{ inputs.base-branch }}" \ - --head "${{ inputs.branch-name }}" \ + --title "${INPUTS_PR_TITLE}" \ + --body "${INPUTS_PR_BODY}" \ + --base "${INPUTS_BASE_BRANCH}" \ + --head "${INPUTS_BRANCH_NAME}" \ --fill) gh pr merge "$PR_URL" --auto diff --git a/.github/actions/npm-auth-token/action.yml b/.github/actions/npm-auth-token/action.yml index 94249d6c51..f9fe4bd894 100644 --- a/.github/actions/npm-auth-token/action.yml +++ b/.github/actions/npm-auth-token/action.yml @@ -30,16 +30,22 @@ runs: id: 'npm_auth_token' shell: 'bash' run: | - AUTH_TOKEN="${{ inputs.github-token }}" - PACKAGE_NAME="${{ inputs.package-name }}" + AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}" + PACKAGE_NAME="${INPUTS_PACKAGE_NAME}" PRIVATE_REPO="@google-gemini/" if [[ "$PACKAGE_NAME" == "$PRIVATE_REPO"* ]]; then - AUTH_TOKEN="${{ inputs.github-token }}" + AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}" elif [[ "$PACKAGE_NAME" == "@google/gemini-cli" ]]; then - AUTH_TOKEN="${{ inputs.wombat-token-cli }}" + AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CLI}" elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-core" ]]; then - AUTH_TOKEN="${{ inputs.wombat-token-core }}" + AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CORE}" elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-a2a-server" ]]; then - AUTH_TOKEN="${{ inputs.wombat-token-a2a-server }}" + AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_A2A_SERVER}" fi echo "auth-token=$AUTH_TOKEN" >> $GITHUB_OUTPUT + env: + INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}' + INPUTS_PACKAGE_NAME: '${{ inputs.package-name }}' + INPUTS_WOMBAT_TOKEN_CLI: '${{ inputs.wombat-token-cli }}' + INPUTS_WOMBAT_TOKEN_CORE: '${{ inputs.wombat-token-core }}' + INPUTS_WOMBAT_TOKEN_A2A_SERVER: '${{ inputs.wombat-token-a2a-server }}' diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index 5c74524ddb..8f062205cb 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -93,15 +93,19 @@ runs: id: 'release_branch' shell: 'bash' run: | - BRANCH_NAME="release/${{ inputs.release-tag }}" + BRANCH_NAME="release/${INPUTS_RELEASE_TAG}" git switch -c "${BRANCH_NAME}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + env: + INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}' - name: '⬆️ Update package versions' working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | - npm run release:version "${{ inputs.release-version }}" + npm run release:version "${INPUTS_RELEASE_VERSION}" + env: + INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}' - name: '💾 Commit and Conditionally Push package versions' working-directory: '${{ inputs.working-directory }}' @@ -163,23 +167,30 @@ runs: working-directory: '${{ inputs.working-directory }}' env: NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' + INPUTS_DRY_RUN: '${{ inputs.dry-run }}' + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' shell: 'bash' run: | npm publish \ - --dry-run="${{ inputs.dry-run }}" \ - --workspace="${{ inputs.core-package-name }}" \ + --dry-run="${INPUTS_DRY_RUN}" \ + --workspace="${INPUTS_CORE_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${{ inputs.core-package-name }} false --silent + npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false --silent - name: '🔗 Install latest core package' working-directory: '${{ inputs.working-directory }}' if: "${{ inputs.dry-run != 'true' }}" shell: 'bash' run: | - npm install "${{ inputs.core-package-name }}@${{ inputs.release-version }}" \ - --workspace="${{ inputs.cli-package-name }}" \ - --workspace="${{ inputs.a2a-package-name }}" \ + npm install "${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_RELEASE_VERSION}" \ + --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ + --workspace="${INPUTS_A2A_PACKAGE_NAME}" \ --save-exact + env: + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}' + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' - name: 'Get CLI Token' uses: './.github/actions/npm-auth-token' @@ -195,13 +206,15 @@ runs: working-directory: '${{ inputs.working-directory }}' env: NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' + INPUTS_DRY_RUN: '${{ inputs.dry-run }}' + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' shell: 'bash' run: | npm publish \ - --dry-run="${{ inputs.dry-run }}" \ - --workspace="${{ inputs.cli-package-name }}" \ + --dry-run="${INPUTS_DRY_RUN}" \ + --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${{ inputs.cli-package-name }} false --silent + npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false --silent - name: 'Get a2a-server Token' uses: './.github/actions/npm-auth-token' @@ -217,14 +230,16 @@ runs: working-directory: '${{ inputs.working-directory }}' env: NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' + INPUTS_DRY_RUN: '${{ inputs.dry-run }}' + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' shell: 'bash' # Tag staging for initial release run: | npm publish \ - --dry-run="${{ inputs.dry-run }}" \ - --workspace="${{ inputs.a2a-package-name }}" \ + --dry-run="${INPUTS_DRY_RUN}" \ + --workspace="${INPUTS_A2A_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${{ inputs.a2a-package-name }} false --silent + npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false --silent - name: '🔬 Verify NPM release by version' uses: './.github/actions/verify-release' @@ -258,13 +273,16 @@ runs: if: "${{ inputs.dry-run != 'true' && inputs.skip-github-release != 'true' && inputs.npm-tag != 'dev' && inputs.npm-registry-url != 'https://npm.pkg.github.com/' }}" env: GITHUB_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}' + INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}' + STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + INPUTS_PREVIOUS_TAG: '${{ inputs.previous-tag }}' shell: 'bash' run: | - gh release create "${{ inputs.release-tag }}" \ + gh release create "${INPUTS_RELEASE_TAG}" \ bundle/gemini.js \ - --target "${{ steps.release_branch.outputs.BRANCH_NAME }}" \ - --title "Release ${{ inputs.release-tag }}" \ - --notes-start-tag "${{ inputs.previous-tag }}" \ + --target "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" \ + --title "Release ${INPUTS_RELEASE_TAG}" \ + --notes-start-tag "${INPUTS_PREVIOUS_TAG}" \ --generate-notes \ ${{ inputs.npm-tag != 'latest' && '--prerelease' || '' }} @@ -274,5 +292,8 @@ runs: continue-on-error: true shell: 'bash' run: | - echo "Cleaning up release branch ${{ steps.release_branch.outputs.BRANCH_NAME }}..." - git push origin --delete "${{ steps.release_branch.outputs.BRANCH_NAME }}" + echo "Cleaning up release branch ${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}..." + git push origin --delete "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" + + env: + STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' diff --git a/.github/actions/push-docker/action.yml b/.github/actions/push-docker/action.yml index 5016d76821..e660733428 100644 --- a/.github/actions/push-docker/action.yml +++ b/.github/actions/push-docker/action.yml @@ -52,8 +52,10 @@ runs: id: 'branch_name' shell: 'bash' run: | - REF_NAME="${{ inputs.ref-name }}" + REF_NAME="${INPUTS_REF_NAME}" echo "name=${REF_NAME%/merge}" >> $GITHUB_OUTPUT + env: + INPUTS_REF_NAME: '${{ inputs.ref-name }}' - name: 'Build and Push the Docker Image' uses: 'docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83' # ratchet:docker/build-push-action@v6 with: diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml index db75ce10cd..e2d1ac942c 100644 --- a/.github/actions/push-sandbox/action.yml +++ b/.github/actions/push-sandbox/action.yml @@ -56,8 +56,8 @@ runs: id: 'image_tag' shell: 'bash' run: |- - SHELL_TAG_NAME="${{ inputs.github-ref-name }}" - FINAL_TAG="${{ inputs.github-sha }}" + SHELL_TAG_NAME="${INPUTS_GITHUB_REF_NAME}" + FINAL_TAG="${INPUTS_GITHUB_SHA}" if [[ "$SHELL_TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then echo "Release detected." FINAL_TAG="${SHELL_TAG_NAME#v}" @@ -66,15 +66,19 @@ runs: fi echo "Determined image tag: $FINAL_TAG" echo "FINAL_TAG=$FINAL_TAG" >> $GITHUB_OUTPUT + env: + INPUTS_GITHUB_REF_NAME: '${{ inputs.github-ref-name }}' + INPUTS_GITHUB_SHA: '${{ inputs.github-sha }}' - name: 'build' id: 'docker_build' shell: 'bash' env: GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' GEMINI_SANDBOX: 'docker' + STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' run: |- npm run build:sandbox -- \ - --image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \ + --image google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG} \ --output-file final_image_uri.txt echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT - name: 'verify' @@ -89,7 +93,9 @@ runs: shell: 'bash' if: "${{ inputs.dry-run != 'true' }}" run: |- - docker push "${{ steps.docker_build.outputs.uri }}" + docker push "${STEPS_DOCKER_BUILD_OUTPUTS_URI}" + env: + STEPS_DOCKER_BUILD_OUTPUTS_URI: '${{ steps.docker_build.outputs.uri }}' - name: 'Create issue on failure' if: |- ${{ failure() }} diff --git a/.github/actions/setup-npmrc/action.yml b/.github/actions/setup-npmrc/action.yml index fba0c14712..137451740f 100644 --- a/.github/actions/setup-npmrc/action.yml +++ b/.github/actions/setup-npmrc/action.yml @@ -18,5 +18,7 @@ runs: shell: 'bash' run: |- echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc - echo ""//npm.pkg.github.com/:_authToken=${{ inputs.github-token }}"" >> ~/.npmrc + echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc + env: + INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}' diff --git a/.github/actions/tag-npm-release/action.yml b/.github/actions/tag-npm-release/action.yml index 7bcafcb6b2..085cf15e99 100644 --- a/.github/actions/tag-npm-release/action.yml +++ b/.github/actions/tag-npm-release/action.yml @@ -71,10 +71,13 @@ runs: ${{ inputs.dry-run != 'true' }} env: NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + INPUTS_VERSION: '${{ inputs.version }}' + INPUTS_CHANNEL: '${{ inputs.channel }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | - npm dist-tag add ${{ inputs.core-package-name }}@${{ inputs.version }} ${{ inputs.channel }} + npm dist-tag add ${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} - name: 'Get cli Token' uses: './.github/actions/npm-auth-token' @@ -91,10 +94,13 @@ runs: ${{ inputs.dry-run != 'true' }} env: NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + INPUTS_VERSION: '${{ inputs.version }}' + INPUTS_CHANNEL: '${{ inputs.channel }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | - npm dist-tag add ${{ inputs.cli-package-name }}@${{ inputs.version }} ${{ inputs.channel }} + npm dist-tag add ${INPUTS_CLI_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} - name: 'Get a2a Token' uses: './.github/actions/npm-auth-token' @@ -111,10 +117,13 @@ runs: ${{ inputs.dry-run == 'false' }} env: NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' + INPUTS_VERSION: '${{ inputs.version }}' + INPUTS_CHANNEL: '${{ inputs.channel }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | - npm dist-tag add ${{ inputs.a2a-package-name }}@${{ inputs.version }} ${{ inputs.channel }} + npm dist-tag add ${INPUTS_A2A_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} - name: 'Log dry run' if: |- @@ -122,4 +131,15 @@ runs: shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | - echo "Dry run: Would have added tag '${{ inputs.channel }}' to version '${{ inputs.version }}' for ${{ inputs.cli-package-name }}, ${{ inputs.core-package-name }}, and ${{ inputs.a2a-package-name }}." + echo "Dry run: Would have added tag '${INPUTS_CHANNEL}' to version '${INPUTS_VERSION}' for ${INPUTS_CLI_PACKAGE_NAME}, ${INPUTS_CORE_PACKAGE_NAME}, and ${INPUTS_A2A_PACKAGE_NAME}." + + env: + INPUTS_CHANNEL: '${{ inputs.channel }}' + + INPUTS_VERSION: '${{ inputs.version }}' + + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' diff --git a/.github/actions/verify-release/action.yml b/.github/actions/verify-release/action.yml index 14b595cb10..261715c1b9 100644 --- a/.github/actions/verify-release/action.yml +++ b/.github/actions/verify-release/action.yml @@ -64,10 +64,13 @@ runs: working-directory: '${{ inputs.working-directory }}' run: |- gemini_version=$(gemini --version) - if [ "$gemini_version" != "${{ inputs.expected-version }}" ]; then - echo "❌ NPM Version mismatch: Got $gemini_version from ${{ inputs.npm-package }}, expected ${{ inputs.expected-version }}" + if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then + echo "❌ NPM Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}" exit 1 fi + env: + INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}' + INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}' - name: 'Clear npm cache' shell: 'bash' @@ -77,11 +80,14 @@ runs: shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: |- - gemini_version=$(npx --prefer-online "${{ inputs.npm-package}}" --version) - if [ "$gemini_version" != "${{ inputs.expected-version }}" ]; then - echo "❌ NPX Run Version mismatch: Got $gemini_version from ${{ inputs.npm-package }}, expected ${{ inputs.expected-version }}" + gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version) + if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then + echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}" exit 1 fi + env: + INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}' + INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}' - name: 'Install dependencies for integration tests' shell: 'bash' diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 4b37d0e109..7d13a23938 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -31,6 +31,7 @@ jobs: name: 'Merge Queue Skipper' permissions: 'read-all' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" outputs: skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}' steps: @@ -42,7 +43,7 @@ jobs: download_repo_name: runs-on: 'gemini-cli-ubuntu-16-core' - if: "${{github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'}}" + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run')" outputs: repo_name: '${{ steps.output-repo-name.outputs.repo_name }}' head_sha: '${{ steps.output-repo-name.outputs.head_sha }}' @@ -53,7 +54,7 @@ jobs: REPO_NAME: '${{ github.event.inputs.repo_name }}' run: | mkdir -p ./pr - echo '${{ env.REPO_NAME }}' > ./pr/repo_name + echo "${REPO_NAME}" > ./pr/repo_name - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'repo_name' @@ -91,7 +92,7 @@ jobs: name: 'Parse run context' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'download_repo_name' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" outputs: repository: '${{ steps.set_context.outputs.REPO }}' sha: '${{ steps.set_context.outputs.SHA }}' @@ -111,11 +112,11 @@ jobs: permissions: 'write-all' needs: - 'parse_run_context' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" steps: - name: 'Set pending status' uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' @@ -131,7 +132,7 @@ jobs: - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') strategy: fail-fast: false matrix: @@ -184,7 +185,7 @@ jobs: - 'parse_run_context' runs-on: 'macos-latest' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 @@ -222,7 +223,7 @@ jobs: - 'merge_queue_skipper' - 'parse_run_context' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') runs-on: 'gemini-cli-windows-16-core' steps: - name: 'Checkout' @@ -282,7 +283,7 @@ jobs: - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 @@ -309,7 +310,7 @@ jobs: e2e: name: 'E2E' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') needs: - 'e2e_linux' - 'e2e_mac' @@ -320,26 +321,31 @@ jobs: steps: - name: 'Check E2E test results' run: | - if [[ ${{ needs.e2e_linux.result }} != 'success' || \ - ${{ needs.e2e_mac.result }} != 'success' || \ - ${{ needs.e2e_windows.result }} != 'success' || \ - ${{ needs.evals.result }} != 'success' ]]; then + if [[ ${NEEDS_E2E_LINUX_RESULT} != 'success' || \ + ${NEEDS_E2E_MAC_RESULT} != 'success' || \ + ${NEEDS_E2E_WINDOWS_RESULT} != 'success' || \ + ${NEEDS_EVALS_RESULT} != 'success' ]]; then echo "One or more E2E jobs failed." exit 1 fi echo "All required E2E jobs passed!" + env: + NEEDS_E2E_LINUX_RESULT: '${{ needs.e2e_linux.result }}' + NEEDS_E2E_MAC_RESULT: '${{ needs.e2e_mac.result }}' + NEEDS_E2E_WINDOWS_RESULT: '${{ needs.e2e_windows.result }}' + NEEDS_EVALS_RESULT: '${{ needs.evals.result }}' set_workflow_status: runs-on: 'gemini-cli-ubuntu-16-core' permissions: 'write-all' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" needs: - 'parse_run_context' - 'e2e' steps: - name: 'Set workflow status' uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd7288cde5..a358ad8b07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: permissions: 'read-all' name: 'Merge Queue Skipper' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" outputs: skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}' steps: @@ -49,7 +50,7 @@ jobs: name: 'Lint' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" env: GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters' steps: @@ -116,6 +117,7 @@ jobs: link_checker: name: 'Link Checker' runs-on: 'ubuntu-latest' + if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 @@ -129,7 +131,7 @@ jobs: runs-on: 'gemini-cli-ubuntu-16-core' needs: - 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' @@ -216,7 +218,7 @@ jobs: runs-on: 'macos-latest' needs: - 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' @@ -311,7 +313,7 @@ jobs: name: 'CodeQL' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: actions: 'read' contents: 'read' @@ -334,7 +336,7 @@ jobs: bundle_size: name: 'Check Bundle Size' needs: 'merge_queue_skipper' - if: "${{github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'" runs-on: 'gemini-cli-ubuntu-16-core' permissions: contents: 'read' # For checkout @@ -359,7 +361,7 @@ jobs: name: 'Slow Test - Win - ${{ matrix.shard }}' runs-on: 'gemini-cli-windows-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" timeout-minutes: 60 strategy: matrix: @@ -451,7 +453,7 @@ jobs: ci: name: 'CI' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" needs: - 'lint' - 'link_checker' @@ -464,14 +466,22 @@ jobs: steps: - name: 'Check all job results' run: | - if [[ (${{ needs.lint.result }} != 'success' && ${{ needs.lint.result }} != 'skipped') || \ - (${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \ - (${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \ - (${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \ - (${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \ - (${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \ - (${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then + if [[ (${NEEDS_LINT_RESULT} != 'success' && ${NEEDS_LINT_RESULT} != 'skipped') || \ + (${NEEDS_LINK_CHECKER_RESULT} != 'success' && ${NEEDS_LINK_CHECKER_RESULT} != 'skipped') || \ + (${NEEDS_TEST_LINUX_RESULT} != 'success' && ${NEEDS_TEST_LINUX_RESULT} != 'skipped') || \ + (${NEEDS_TEST_MAC_RESULT} != 'success' && ${NEEDS_TEST_MAC_RESULT} != 'skipped') || \ + (${NEEDS_TEST_WINDOWS_RESULT} != 'success' && ${NEEDS_TEST_WINDOWS_RESULT} != 'skipped') || \ + (${NEEDS_CODEQL_RESULT} != 'success' && ${NEEDS_CODEQL_RESULT} != 'skipped') || \ + (${NEEDS_BUNDLE_SIZE_RESULT} != 'success' && ${NEEDS_BUNDLE_SIZE_RESULT} != 'skipped') ]]; then echo "One or more CI jobs failed." exit 1 fi echo "All CI jobs passed!" + env: + NEEDS_LINT_RESULT: '${{ needs.lint.result }}' + NEEDS_LINK_CHECKER_RESULT: '${{ needs.link_checker.result }}' + NEEDS_TEST_LINUX_RESULT: '${{ needs.test_linux.result }}' + NEEDS_TEST_MAC_RESULT: '${{ needs.test_mac.result }}' + NEEDS_TEST_WINDOWS_RESULT: '${{ needs.test_windows.result }}' + NEEDS_CODEQL_RESULT: '${{ needs.codeql.result }}' + NEEDS_BUNDLE_SIZE_RESULT: '${{ needs.bundle_size.result }}' diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index c9f4c3d59f..fbb3e2d8d7 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -27,6 +27,7 @@ jobs: deflake_e2e_linux: name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" strategy: fail-fast: false matrix: @@ -68,15 +69,16 @@ jobs: VERBOSE: 'true' shell: 'bash' run: | - if [[ "${{ env.IS_DOCKER }}" == "true" ]]; then - npm run deflake:test:integration:sandbox:docker -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'" + if [[ "${IS_DOCKER}" == "true" ]]; then + npm run deflake:test:integration:sandbox:docker -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" else - npm run deflake:test:integration:sandbox:none -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'" + npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" fi deflake_e2e_mac: name: 'E2E Test (macOS)' runs-on: 'macos-latest' + if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 @@ -109,11 +111,12 @@ jobs: TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' VERBOSE: 'true' run: | - npm run deflake:test:integration:sandbox:none -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'" + npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" deflake_e2e_windows: name: 'Slow E2E - Win' runs-on: 'gemini-cli-windows-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" steps: - name: 'Checkout' @@ -167,4 +170,4 @@ jobs: TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' shell: 'pwsh' run: | - npm run deflake:test:integration:sandbox:none -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'" + npm run deflake:test:integration:sandbox:none -- --runs="$env:RUNS" -- --testNamePattern "'$env:TEST_NAME_PATTERN'" diff --git a/.github/workflows/docs-page-action.yml b/.github/workflows/docs-page-action.yml index 2d485278ce..be807c7c36 100644 --- a/.github/workflows/docs-page-action.yml +++ b/.github/workflows/docs-page-action.yml @@ -19,8 +19,7 @@ concurrency: jobs: build: - if: |- - ${{ !contains(github.ref_name, 'nightly') }} + if: "github.repository == 'google-gemini/gemini-cli' && !contains(github.ref_name, 'nightly')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' @@ -39,6 +38,7 @@ jobs: uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3 deploy: + if: "github.repository == 'google-gemini/gemini-cli'" environment: name: 'github-pages' url: '${{ steps.deployment.outputs.page_url }}' diff --git a/.github/workflows/docs-rebuild.yml b/.github/workflows/docs-rebuild.yml index ac41819f02..a4e2c65973 100644 --- a/.github/workflows/docs-rebuild.yml +++ b/.github/workflows/docs-rebuild.yml @@ -7,6 +7,7 @@ on: - 'docs/**' jobs: trigger-rebuild: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' steps: - name: 'Trigger rebuild' diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index d5616a3419..23dc1cfdfb 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -44,5 +44,5 @@ jobs: - name: 'Run evaluation' working-directory: '/app' run: | - poetry run exp_run --experiment-mode=on-demand --branch-or-commit=${{ github.ref_name }} --model-name=gemini-2.5-pro --dataset=swebench_verified --concurrency=15 + poetry run exp_run --experiment-mode=on-demand --branch-or-commit="${GITHUB_REF_NAME}" --model-name=gemini-2.5-pro --dataset=swebench_verified --concurrency=15 poetry run python agent_prototypes/scripts/parse_gcli_logs_experiment.py --experiment_dir=experiments/adhoc/gcli_temp_exp --gcs-bucket="${EVAL_GCS_BUCKET}" --gcs-path=gh_action_artifacts diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml index 6f6767ebfe..c5b3709c75 100644 --- a/.github/workflows/evals-nightly.yml +++ b/.github/workflows/evals-nightly.yml @@ -23,6 +23,7 @@ jobs: evals: name: 'Evals (USUALLY_PASSING) nightly run' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" strategy: fail-fast: false matrix: @@ -62,7 +63,7 @@ jobs: TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' run: | CMD="npm run test:all_evals" - PATTERN="${{ env.TEST_NAME_PATTERN }}" + PATTERN="${TEST_NAME_PATTERN}" if [[ -n "$PATTERN" ]]; then if [[ "$PATTERN" == *.ts || "$PATTERN" == *.js || "$PATTERN" == */* ]]; then @@ -85,7 +86,7 @@ jobs: aggregate-results: name: 'Aggregate Results' needs: ['evals'] - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Checkout' diff --git a/.github/workflows/gemini-scheduled-stale-issue-closer.yml b/.github/workflows/gemini-scheduled-stale-issue-closer.yml index c7aef65a73..2b7b163d88 100644 --- a/.github/workflows/gemini-scheduled-stale-issue-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-issue-closer.yml @@ -21,6 +21,7 @@ defaults: jobs: close-stale-issues: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' diff --git a/.github/workflows/label-backlog-child-issues.yml b/.github/workflows/label-backlog-child-issues.yml index b11f509f80..a819bf4e71 100644 --- a/.github/workflows/label-backlog-child-issues.yml +++ b/.github/workflows/label-backlog-child-issues.yml @@ -14,7 +14,7 @@ permissions: jobs: # Event-based: Quick reaction to new/edited issues in THIS repo labeler: - if: "github.event_name == 'issues'" + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' @@ -36,7 +36,7 @@ jobs: # Scheduled/Manual: Recursive sync across multiple repos sync-maintainer-labels: - if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' diff --git a/.github/workflows/label-workstream-rollup.yml b/.github/workflows/label-workstream-rollup.yml index 35840cfe6f..97d699d09b 100644 --- a/.github/workflows/label-workstream-rollup.yml +++ b/.github/workflows/label-workstream-rollup.yml @@ -9,6 +9,7 @@ on: jobs: labeler: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' diff --git a/.github/workflows/release-change-tags.yml b/.github/workflows/release-change-tags.yml index 6184850677..c7c3f3f2d2 100644 --- a/.github/workflows/release-change-tags.yml +++ b/.github/workflows/release-change-tags.yml @@ -32,6 +32,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml index c9d2290a1c..f03bd52127 100644 --- a/.github/workflows/release-manual.yml +++ b/.github/workflows/release-manual.yml @@ -47,6 +47,7 @@ on: jobs: release: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 8a681dadf6..f746e65c2e 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -22,6 +22,7 @@ on: jobs: generate-release-notes: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' diff --git a/.github/workflows/release-patch-3-release.yml b/.github/workflows/release-patch-3-release.yml index b0d459f256..6680362a16 100644 --- a/.github/workflows/release-patch-3-release.yml +++ b/.github/workflows/release-patch-3-release.yml @@ -118,6 +118,7 @@ jobs: ORIGINAL_RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' ORIGINAL_RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' ORIGINAL_PREVIOUS_TAG: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' + VARS_CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' run: | echo "🔍 Verifying no concurrent patch releases have occurred..." @@ -129,7 +130,7 @@ jobs: # Re-run the same version calculation script echo "Re-calculating version to check for changes..." - CURRENT_PATCH_JSON=$(node scripts/get-release-version.js --cli-package-name="${{vars.CLI_PACKAGE_NAME}}" --type=patch --patch-from="${CHANNEL}") + CURRENT_PATCH_JSON=$(node scripts/get-release-version.js --cli-package-name="${VARS_CLI_PACKAGE_NAME}" --type=patch --patch-from="${CHANNEL}") CURRENT_RELEASE_VERSION=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseVersion) CURRENT_RELEASE_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseTag) CURRENT_PREVIOUS_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .previousReleaseTag) @@ -162,10 +163,15 @@ jobs: - name: 'Print Calculated Version' run: |- echo "Patch Release Summary:" - echo " Release Version: ${{ steps.patch_version.outputs.RELEASE_VERSION }}" - echo " Release Tag: ${{ steps.patch_version.outputs.RELEASE_TAG }}" - echo " NPM Tag: ${{ steps.patch_version.outputs.NPM_TAG }}" - echo " Previous Tag: ${{ steps.patch_version.outputs.PREVIOUS_TAG }}" + echo " Release Version: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION}" + echo " Release Tag: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG}" + echo " NPM Tag: ${STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG}" + echo " Previous Tag: ${STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG}" + env: + STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' + STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' + STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' - name: 'Run Tests' if: "${{github.event.inputs.force_skip_tests != 'true'}}" diff --git a/.github/workflows/release-promote.yml b/.github/workflows/release-promote.yml index ebe16b1a39..d5c16b94fe 100644 --- a/.github/workflows/release-promote.yml +++ b/.github/workflows/release-promote.yml @@ -362,23 +362,28 @@ jobs: - name: 'Create and switch to a new branch' id: 'release_branch' run: | - BRANCH_NAME="chore/nightly-version-bump-${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}" + BRANCH_NAME="chore/nightly-version-bump-${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}" git switch -c "${BRANCH_NAME}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + env: + NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' - name: 'Update package versions' - run: 'npm run release:version "${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}"' + run: 'npm run release:version "${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}"' + env: + NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' - name: 'Commit and Push package versions' env: BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' DRY_RUN: '${{ github.event.inputs.dry_run }}' + NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' run: |- git add package.json packages/*/package.json if [ -f package-lock.json ]; then git add package-lock.json fi - git commit -m "chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}" + git commit -m "chore(release): bump version to ${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}" if [[ "${DRY_RUN}" == "false" ]]; then echo "Pushing release branch to remote..." git push --set-upstream origin "${BRANCH_NAME}" diff --git a/.github/workflows/release-rollback.yml b/.github/workflows/release-rollback.yml index 75c2d0c799..db91457b1a 100644 --- a/.github/workflows/release-rollback.yml +++ b/.github/workflows/release-rollback.yml @@ -42,6 +42,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" runs-on: 'ubuntu-latest' permissions: @@ -203,7 +204,7 @@ jobs: run: | ROLLBACK_COMMIT=$(git rev-parse -q --verify "$TARGET_TAG") if [ "$ROLLBACK_COMMIT" != "$TARGET_HASH" ]; then - echo '❌ Failed to add tag $TARGET_TAG to commit $TARGET_HASH' + echo "❌ Failed to add tag ${TARGET_TAG} to commit ${TARGET_HASH}" echo '❌ This means the tag was not added, and the workflow should fail.' exit 1 fi diff --git a/.github/workflows/release-sandbox.yml b/.github/workflows/release-sandbox.yml index f1deb0380c..2c7de7a0f5 100644 --- a/.github/workflows/release-sandbox.yml +++ b/.github/workflows/release-sandbox.yml @@ -16,6 +16,7 @@ on: jobs: build: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'read' diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index caeb0bebe0..29903dfbe8 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -20,6 +20,7 @@ on: jobs: smoke-test: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' diff --git a/.github/workflows/trigger_e2e.yml b/.github/workflows/trigger_e2e.yml index 52b3a26f6f..56da2727c5 100644 --- a/.github/workflows/trigger_e2e.yml +++ b/.github/workflows/trigger_e2e.yml @@ -15,6 +15,7 @@ on: jobs: save_repo_name: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Save Repo name' @@ -23,14 +24,15 @@ jobs: HEAD_SHA: '${{ github.event.inputs.head_sha || github.event.pull_request.head.sha }}' run: | mkdir -p ./pr - echo '${{ env.REPO_NAME }}' > ./pr/repo_name - echo '${{ env.HEAD_SHA }}' > ./pr/head_sha + echo "${REPO_NAME}" > ./pr/repo_name + echo "${HEAD_SHA}" > ./pr/head_sha - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'repo_name' path: 'pr/' trigger_e2e: name: 'Trigger e2e' + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'gemini-cli-ubuntu-16-core' steps: - id: 'trigger-e2e' diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index edf0995ddd..20a9f51b8a 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -28,6 +28,7 @@ on: jobs: verify-release: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" strategy: fail-fast: false diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 8fb3f6aa87..5390f89fa3 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.30.0 +# Latest stable release: v0.30.1 -Released: February 25, 2026 +Released: February 27, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -28,6 +28,9 @@ npm install -g @google/gemini-cli ## What's Changed +- fix(patch): cherry-pick 58df1c6 to release/v0.30.0-pr-20374 [CONFLICTS] by + @gemini-cli-robot in + [#20567](https://github.com/google-gemini/gemini-cli/pull/20567) - feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in [#18240](https://github.com/google-gemini/gemini-cli/pull/18240) - Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by @@ -330,4 +333,4 @@ npm install -g @google/gemini-cli [#20112](https://github.com/google-gemini/gemini-cli/pull/20112) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.0 +https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.1 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 646106fa50..e855c7913b 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.30.0-preview.5 +# Preview release: v0.31.0-preview.1 -Released: February 24, 2026 +Released: February 27, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -13,306 +13,404 @@ npm install -g @google/gemini-cli@preview ## Highlights -- **Initial SDK Package:** Introduced the initial SDK package with support for - custom skills and dynamic system instructions. -- **Refined Plan Mode:** Refined Plan Mode with support for enabling skills, - improved agentic execution, and project exploration without planning. -- **Enhanced CLI UI:** Enhanced CLI UI with a new clean UI toggle, minimal-mode - bleed-through, and support for Ctrl-Z suspension. -- **`--policy` flag:** Added the `--policy` flag to support user-defined - policies. -- **New Themes:** Added Solarized Dark and Solarized Light themes. +- **Plan Mode Enhancements**: Numerous additions including automatic model + switching, custom storage directory configuration, message injection upon + manual exit, enforcement of read-only constraints, and centralized tool + visibility in the policy engine. +- **Policy Engine Updates**: Project-level policy support added, alongside MCP + server wildcard support, tool annotation propagation and matching, and + workspace-level "Always Allow" persistence. +- **MCP Integration Improvements**: Better integration through support for MCP + progress updates with input validation and throttling, environment variable + expansion for servers, and full details expansion on tool approval. +- **CLI & Core UX Enhancements**: Several UI and quality-of-life updates such as + Alt+D for forward word deletion, macOS run-event notifications, enhanced + folder trust configurations with security warnings, improved startup warnings, + and a new experimental browser agent. +- **Security & Stability**: Introduced the Conseca framework, deceptive URL and + Unicode character detection, stricter access checks, rate limits on web fetch, + and resolved multiple dependency vulnerabilities. ## What's Changed -- fix(patch): cherry-pick 2c1d6f8 to release/v0.30.0-preview.4-pr-19369 to patch - version v0.30.0-preview.4 and create version 0.30.0-preview.5 by +- fix(patch): cherry-pick 58df1c6 to release/v0.31.0-preview.0-pr-20374 to patch + version v0.31.0-preview.0 and create version 0.31.0-preview.1 by @gemini-cli-robot in - [#20086](https://github.com/google-gemini/gemini-cli/pull/20086) -- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch - version v0.30.0-preview.0 and create version 0.30.0-preview.1 by - @gemini-cli-robot in - [#19490](https://github.com/google-gemini/gemini-cli/pull/19490) -- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in - [#18240](https://github.com/google-gemini/gemini-cli/pull/18240) -- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by - @skeshive in [#18771](https://github.com/google-gemini/gemini-cli/pull/18771) -- chore(release): bump version to 0.30.0-nightly.20260210.a2174751d by - @gemini-cli-robot in - [#18772](https://github.com/google-gemini/gemini-cli/pull/18772) -- chore: cleanup unused and add unlisted dependencies in packages/core by - @adamfweidman in - [#18762](https://github.com/google-gemini/gemini-cli/pull/18762) -- chore(core): update activate_skill prompt verbiage to be more direct by - @NTaylorMullen in - [#18605](https://github.com/google-gemini/gemini-cli/pull/18605) -- Add autoconfigure memory usage setting to the dialog by @jacob314 in - [#18510](https://github.com/google-gemini/gemini-cli/pull/18510) -- fix(core): prevent race condition in policy persistence by @braddux in - [#18506](https://github.com/google-gemini/gemini-cli/pull/18506) -- fix(evals): prevent false positive in hierarchical memory test by - @Abhijit-2592 in - [#18777](https://github.com/google-gemini/gemini-cli/pull/18777) -- test(evals): mark all `save_memory` evals as `USUALLY_PASSES` due to - unreliability by @jerop in - [#18786](https://github.com/google-gemini/gemini-cli/pull/18786) -- feat(cli): add setting to hide shortcuts hint UI by @LyalinDotCom in - [#18562](https://github.com/google-gemini/gemini-cli/pull/18562) -- feat(core): formalize 5-phase sequential planning workflow by @jerop in - [#18759](https://github.com/google-gemini/gemini-cli/pull/18759) -- Introduce limits for search results. by @gundermanc in - [#18767](https://github.com/google-gemini/gemini-cli/pull/18767) -- fix(cli): allow closing debug console after auto-open via flicker by + [#20568](https://github.com/google-gemini/gemini-cli/pull/20568) +- Use ranged reads and limited searches and fuzzy editing improvements by + @gundermanc in + [#19240](https://github.com/google-gemini/gemini-cli/pull/19240) +- Fix bottom border color by @jacob314 in + [#19266](https://github.com/google-gemini/gemini-cli/pull/19266) +- Release note generator fix by @g-samroberts in + [#19363](https://github.com/google-gemini/gemini-cli/pull/19363) +- test(evals): add behavioral tests for tool output masking by @NTaylorMullen in + [#19172](https://github.com/google-gemini/gemini-cli/pull/19172) +- docs: clarify preflight instructions in GEMINI.md by @NTaylorMullen in + [#19377](https://github.com/google-gemini/gemini-cli/pull/19377) +- feat(cli): add gemini --resume hint on exit by @Mag1ck in + [#16285](https://github.com/google-gemini/gemini-cli/pull/16285) +- fix: optimize height calculations for ask_user dialog by @jackwotherspoon in + [#19017](https://github.com/google-gemini/gemini-cli/pull/19017) +- feat(cli): add Alt+D for forward word deletion by @scidomino in + [#19300](https://github.com/google-gemini/gemini-cli/pull/19300) +- Disable failing eval test by @chrstnb in + [#19455](https://github.com/google-gemini/gemini-cli/pull/19455) +- fix(cli): support legacy onConfirm callback in ToolActionsContext by @SandyTao520 in - [#18795](https://github.com/google-gemini/gemini-cli/pull/18795) -- feat(masking): enable tool output masking by default by @abhipatel12 in - [#18564](https://github.com/google-gemini/gemini-cli/pull/18564) -- perf(ui): optimize table rendering by memoizing styled characters by @devr0306 - in [#18770](https://github.com/google-gemini/gemini-cli/pull/18770) -- feat: multi-line text answers in ask-user tool by @jackwotherspoon in - [#18741](https://github.com/google-gemini/gemini-cli/pull/18741) -- perf(cli): truncate large debug logs and limit message history by @mattKorwel - in [#18663](https://github.com/google-gemini/gemini-cli/pull/18663) -- fix(core): complete MCP discovery when configured servers are skipped by + [#19369](https://github.com/google-gemini/gemini-cli/pull/19369) +- chore(deps): bump tar from 7.5.7 to 7.5.8 by dependabot[bot] in + [#19367](https://github.com/google-gemini/gemini-cli/pull/19367) +- fix(plan): allow safe fallback when experiment setting for plan is not enabled + but approval mode at startup is plan by @Adib234 in + [#19439](https://github.com/google-gemini/gemini-cli/pull/19439) +- Add explicit color-convert dependency by @chrstnb in + [#19460](https://github.com/google-gemini/gemini-cli/pull/19460) +- feat(devtools): migrate devtools package into monorepo by @SandyTao520 in + [#18936](https://github.com/google-gemini/gemini-cli/pull/18936) +- fix(core): clarify plan mode constraints and exit mechanism by @jerop in + [#19438](https://github.com/google-gemini/gemini-cli/pull/19438) +- feat(cli): add macOS run-event notifications (interactive only) by @LyalinDotCom in - [#18586](https://github.com/google-gemini/gemini-cli/pull/18586) -- fix(core): cache CLI version to ensure consistency during sessions by - @sehoon38 in [#18793](https://github.com/google-gemini/gemini-cli/pull/18793) -- fix(cli): resolve double rendering in shpool and address vscode lint warnings - by @braddux in - [#18704](https://github.com/google-gemini/gemini-cli/pull/18704) -- feat(plan): document and validate Plan Mode policy overrides by @jerop in - [#18825](https://github.com/google-gemini/gemini-cli/pull/18825) -- Fix pressing any key to exit select mode. by @jacob314 in - [#18421](https://github.com/google-gemini/gemini-cli/pull/18421) -- fix(cli): update F12 behavior to only open drawer if browser fails by + [#19056](https://github.com/google-gemini/gemini-cli/pull/19056) +- Changelog for v0.29.0 by @gemini-cli-robot in + [#19361](https://github.com/google-gemini/gemini-cli/pull/19361) +- fix(ui): preventing empty history items from being added by @devr0306 in + [#19014](https://github.com/google-gemini/gemini-cli/pull/19014) +- Changelog for v0.30.0-preview.0 by @gemini-cli-robot in + [#19364](https://github.com/google-gemini/gemini-cli/pull/19364) +- feat(core): add support for MCP progress updates by @NTaylorMullen in + [#19046](https://github.com/google-gemini/gemini-cli/pull/19046) +- fix(core): ensure directory exists before writing conversation file by + @godwiniheuwa in + [#18429](https://github.com/google-gemini/gemini-cli/pull/18429) +- fix(ui): move margin from top to bottom in ToolGroupMessage by @imadraude in + [#17198](https://github.com/google-gemini/gemini-cli/pull/17198) +- fix(cli): treat unknown slash commands as regular input instead of showing + error by @skyvanguard in + [#17393](https://github.com/google-gemini/gemini-cli/pull/17393) +- feat(core): experimental in-progress steering hints (2 of 2) by @joshualitt in + [#19307](https://github.com/google-gemini/gemini-cli/pull/19307) +- docs(plan): add documentation for plan mode command by @Adib234 in + [#19467](https://github.com/google-gemini/gemini-cli/pull/19467) +- fix(core): ripgrep fails when pattern looks like ripgrep flag by @syvb in + [#18858](https://github.com/google-gemini/gemini-cli/pull/18858) +- fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling by + @NTaylorMullen in + [#19451](https://github.com/google-gemini/gemini-cli/pull/19451) +- use issuer instead of authorization_endpoint for oauth discovery by + @garrettsparks in + [#17332](https://github.com/google-gemini/gemini-cli/pull/17332) +- feat(cli): include `/dir add` directories in @ autocomplete suggestions by + @jasmeetsb in [#19246](https://github.com/google-gemini/gemini-cli/pull/19246) +- feat(admin): Admin settings should only apply if adminControlsApplicable = + true and fetch errors should be fatal by @skeshive in + [#19453](https://github.com/google-gemini/gemini-cli/pull/19453) +- Format strict-development-rules command by @g-samroberts in + [#19484](https://github.com/google-gemini/gemini-cli/pull/19484) +- feat(core): centralize compatibility checks and add TrueColor detection by + @spencer426 in + [#19478](https://github.com/google-gemini/gemini-cli/pull/19478) +- Remove unused files and update index and sidebar. by @g-samroberts in + [#19479](https://github.com/google-gemini/gemini-cli/pull/19479) +- Migrate core render util to use xterm.js as part of the rendering loop. by + @jacob314 in [#19044](https://github.com/google-gemini/gemini-cli/pull/19044) +- Changelog for v0.30.0-preview.1 by @gemini-cli-robot in + [#19496](https://github.com/google-gemini/gemini-cli/pull/19496) +- build: replace deprecated built-in punycode with userland package by @jacob314 + in [#19502](https://github.com/google-gemini/gemini-cli/pull/19502) +- Speculative fixes to try to fix react error. by @jacob314 in + [#19508](https://github.com/google-gemini/gemini-cli/pull/19508) +- fix spacing by @jacob314 in + [#19494](https://github.com/google-gemini/gemini-cli/pull/19494) +- fix(core): ensure user rejections update tool outcome for telemetry by + @abhiasap in [#18982](https://github.com/google-gemini/gemini-cli/pull/18982) +- fix(acp): Initialize config (#18897) by @Mervap in + [#18898](https://github.com/google-gemini/gemini-cli/pull/18898) +- fix(core): add error logging for IDE fetch failures by @yuvrajangadsingh in + [#17981](https://github.com/google-gemini/gemini-cli/pull/17981) +- feat(acp): support set_mode interface (#18890) by @Mervap in + [#18891](https://github.com/google-gemini/gemini-cli/pull/18891) +- fix(core): robust workspace-based IDE connection discovery by @ehedlund in + [#18443](https://github.com/google-gemini/gemini-cli/pull/18443) +- Deflake windows tests. by @jacob314 in + [#19511](https://github.com/google-gemini/gemini-cli/pull/19511) +- Fix: Avoid tool confirmation timeout when no UI listeners are present by + @pdHaku0 in [#17955](https://github.com/google-gemini/gemini-cli/pull/17955) +- format md file by @scidomino in + [#19474](https://github.com/google-gemini/gemini-cli/pull/19474) +- feat(cli): add experimental.useOSC52Copy setting by @scidomino in + [#19488](https://github.com/google-gemini/gemini-cli/pull/19488) +- feat(cli): replace loading phrases boolean with enum setting by @LyalinDotCom + in [#19347](https://github.com/google-gemini/gemini-cli/pull/19347) +- Update skill to adjust for generated results. by @g-samroberts in + [#19500](https://github.com/google-gemini/gemini-cli/pull/19500) +- Fix message too large issue. by @gundermanc in + [#19499](https://github.com/google-gemini/gemini-cli/pull/19499) +- fix(core): prevent duplicate tool approval entries in auto-saved.toml by + @Abhijit-2592 in + [#19487](https://github.com/google-gemini/gemini-cli/pull/19487) +- fix(core): resolve crash in ClearcutLogger when os.cpus() is empty by @Adib234 + in [#19555](https://github.com/google-gemini/gemini-cli/pull/19555) +- chore(core): improve encapsulation and remove unused exports by @adamfweidman + in [#19556](https://github.com/google-gemini/gemini-cli/pull/19556) +- Revert "Add generic searchable list to back settings and extensions (… by + @chrstnb in [#19434](https://github.com/google-gemini/gemini-cli/pull/19434) +- fix(core): improve error type extraction for telemetry by @yunaseoul in + [#19565](https://github.com/google-gemini/gemini-cli/pull/19565) +- fix: remove extra padding in Composer by @jackwotherspoon in + [#19529](https://github.com/google-gemini/gemini-cli/pull/19529) +- feat(plan): support configuring custom plans storage directory by @jerop in + [#19577](https://github.com/google-gemini/gemini-cli/pull/19577) +- Migrate files to resource or references folder. by @g-samroberts in + [#19503](https://github.com/google-gemini/gemini-cli/pull/19503) +- feat(policy): implement project-level policy support by @Abhijit-2592 in + [#18682](https://github.com/google-gemini/gemini-cli/pull/18682) +- feat(core): Implement parallel FC for read only tools. by @joshualitt in + [#18791](https://github.com/google-gemini/gemini-cli/pull/18791) +- chore(skills): adds pr-address-comments skill to work on PR feedback by + @mbleigh in [#19576](https://github.com/google-gemini/gemini-cli/pull/19576) +- refactor(sdk): introduce session-based architecture by @mbleigh in + [#19180](https://github.com/google-gemini/gemini-cli/pull/19180) +- fix(ci): add fallback JSON extraction to issue triage workflow by @bdmorgan in + [#19593](https://github.com/google-gemini/gemini-cli/pull/19593) +- feat(core): refine Edit and WriteFile tool schemas for Gemini 3 by @SandyTao520 in - [#18829](https://github.com/google-gemini/gemini-cli/pull/18829) -- feat(plan): allow skills to be enabled in plan mode by @Adib234 in - [#18817](https://github.com/google-gemini/gemini-cli/pull/18817) -- docs(plan): add documentation for plan mode tools by @jerop in - [#18827](https://github.com/google-gemini/gemini-cli/pull/18827) -- Remove experimental note in extension settings docs by @chrstnb in - [#18822](https://github.com/google-gemini/gemini-cli/pull/18822) -- Update prompt and grep tool definition to limit context size by @gundermanc in - [#18780](https://github.com/google-gemini/gemini-cli/pull/18780) -- docs(plan): add `ask_user` tool documentation by @jerop in - [#18830](https://github.com/google-gemini/gemini-cli/pull/18830) -- Revert unintended credentials exposure by @Adib234 in - [#18840](https://github.com/google-gemini/gemini-cli/pull/18840) -- feat(core): update internal utility models to Gemini 3 by @SandyTao520 in - [#18773](https://github.com/google-gemini/gemini-cli/pull/18773) -- feat(a2a): add value-resolver for auth credential resolution by @adamfweidman - in [#18653](https://github.com/google-gemini/gemini-cli/pull/18653) -- Removed getPlainTextLength by @devr0306 in - [#18848](https://github.com/google-gemini/gemini-cli/pull/18848) -- More grep prompt tweaks by @gundermanc in - [#18846](https://github.com/google-gemini/gemini-cli/pull/18846) -- refactor(cli): Reactive useSettingsStore hook by @psinha40898 in - [#14915](https://github.com/google-gemini/gemini-cli/pull/14915) -- fix(mcp): Ensure that stdio MCP server execution has the `GEMINI_CLI=1` env - variable populated. by @richieforeman in - [#18832](https://github.com/google-gemini/gemini-cli/pull/18832) -- fix(core): improve headless mode detection for flags and query args by @galz10 - in [#18855](https://github.com/google-gemini/gemini-cli/pull/18855) -- refactor(cli): simplify UI and remove legacy inline tool confirmation logic by - @abhipatel12 in - [#18566](https://github.com/google-gemini/gemini-cli/pull/18566) -- feat(cli): deprecate --allowed-tools and excludeTools in favor of policy - engine by @Abhijit-2592 in - [#18508](https://github.com/google-gemini/gemini-cli/pull/18508) -- fix(workflows): improve maintainer detection for automated PR actions by - @bdmorgan in [#18869](https://github.com/google-gemini/gemini-cli/pull/18869) -- refactor(cli): consolidate useToolScheduler and delete legacy implementation - by @abhipatel12 in - [#18567](https://github.com/google-gemini/gemini-cli/pull/18567) -- Update changelog for v0.28.0 and v0.29.0-preview0 by @g-samroberts in - [#18819](https://github.com/google-gemini/gemini-cli/pull/18819) -- fix(core): ensure sub-agents are registered regardless of tools.allowed by + [#19476](https://github.com/google-gemini/gemini-cli/pull/19476) +- Changelog for v0.30.0-preview.3 by @gemini-cli-robot in + [#19585](https://github.com/google-gemini/gemini-cli/pull/19585) +- fix(plan): exclude EnterPlanMode tool from YOLO mode by @Adib234 in + [#19570](https://github.com/google-gemini/gemini-cli/pull/19570) +- chore: resolve build warnings and update dependencies by @mattKorwel in + [#18880](https://github.com/google-gemini/gemini-cli/pull/18880) +- feat(ui): add source indicators to slash commands by @ehedlund in + [#18839](https://github.com/google-gemini/gemini-cli/pull/18839) +- docs: refine Plan Mode documentation structure and workflow by @jerop in + [#19644](https://github.com/google-gemini/gemini-cli/pull/19644) +- Docs: Update release information regarding Gemini 3.1 by @jkcinouye in + [#19568](https://github.com/google-gemini/gemini-cli/pull/19568) +- fix(security): rate limit web_fetch tool to mitigate DDoS via prompt injection + by @mattKorwel in + [#19567](https://github.com/google-gemini/gemini-cli/pull/19567) +- Add initial implementation of /extensions explore command by @chrstnb in + [#19029](https://github.com/google-gemini/gemini-cli/pull/19029) +- fix: use discoverOAuthFromWWWAuthenticate for reactive OAuth flow (#18760) by + @maximus12793 in + [#19038](https://github.com/google-gemini/gemini-cli/pull/19038) +- Search updates by @alisa-alisa in + [#19482](https://github.com/google-gemini/gemini-cli/pull/19482) +- feat(cli): add support for numpad SS3 sequences by @scidomino in + [#19659](https://github.com/google-gemini/gemini-cli/pull/19659) +- feat(cli): enhance folder trust with configuration discovery and security + warnings by @galz10 in + [#19492](https://github.com/google-gemini/gemini-cli/pull/19492) +- feat(ui): improve startup warnings UX with dismissal and show-count limits by + @spencer426 in + [#19584](https://github.com/google-gemini/gemini-cli/pull/19584) +- feat(a2a): Add API key authentication provider by @adamfweidman in + [#19548](https://github.com/google-gemini/gemini-cli/pull/19548) +- Send accepted/removed lines with ACCEPT_FILE telemetry. by @gundermanc in + [#19670](https://github.com/google-gemini/gemini-cli/pull/19670) +- feat(models): support Gemini 3.1 Pro Preview and fixes by @sehoon38 in + [#19676](https://github.com/google-gemini/gemini-cli/pull/19676) +- feat(plan): enforce read-only constraints in Plan Mode by @mattKorwel in + [#19433](https://github.com/google-gemini/gemini-cli/pull/19433) +- fix(cli): allow perfect match @scripts/test-windows-paths.js completions to + submit on Enter by @spencer426 in + [#19562](https://github.com/google-gemini/gemini-cli/pull/19562) +- fix(core): treat 503 Service Unavailable as retryable quota error by @sehoon38 + in [#19642](https://github.com/google-gemini/gemini-cli/pull/19642) +- Update sidebar.json for to allow top nav tabs. by @g-samroberts in + [#19595](https://github.com/google-gemini/gemini-cli/pull/19595) +- security: strip deceptive Unicode characters from terminal output by @ehedlund + in [#19026](https://github.com/google-gemini/gemini-cli/pull/19026) +- Fixes 'input.on' is not a function error in Gemini CLI by @gundermanc in + [#19691](https://github.com/google-gemini/gemini-cli/pull/19691) +- Revert "feat(ui): add source indicators to slash commands" by @ehedlund in + [#19695](https://github.com/google-gemini/gemini-cli/pull/19695) +- security: implement deceptive URL detection and disclosure in tool + confirmations by @ehedlund in + [#19288](https://github.com/google-gemini/gemini-cli/pull/19288) +- fix(core): restore auth consent in headless mode and add unit tests by + @ehedlund in [#19689](https://github.com/google-gemini/gemini-cli/pull/19689) +- Fix unsafe assertions in code_assist folder. by @gundermanc in + [#19706](https://github.com/google-gemini/gemini-cli/pull/19706) +- feat(cli): make JetBrains warning more specific by @jacob314 in + [#19687](https://github.com/google-gemini/gemini-cli/pull/19687) +- fix(cli): extensions dialog UX polish by @jacob314 in + [#19685](https://github.com/google-gemini/gemini-cli/pull/19685) +- fix(cli): use getDisplayString for manual model selection in dialog by + @sehoon38 in [#19726](https://github.com/google-gemini/gemini-cli/pull/19726) +- feat(policy): repurpose "Always Allow" persistence to workspace level by + @Abhijit-2592 in + [#19707](https://github.com/google-gemini/gemini-cli/pull/19707) +- fix(cli): re-enable CLI banner by @sehoon38 in + [#19741](https://github.com/google-gemini/gemini-cli/pull/19741) +- Disallow and suppress unsafe assignment by @gundermanc in + [#19736](https://github.com/google-gemini/gemini-cli/pull/19736) +- feat(core): migrate read_file to 1-based start_line/end_line parameters by + @adamfweidman in + [#19526](https://github.com/google-gemini/gemini-cli/pull/19526) +- feat(cli): improve CTRL+O experience for both standard and alternate screen + buffer (ASB) modes by @jwhelangoog in + [#19010](https://github.com/google-gemini/gemini-cli/pull/19010) +- Utilize pipelining of grep_search -> read_file to eliminate turns by + @gundermanc in + [#19574](https://github.com/google-gemini/gemini-cli/pull/19574) +- refactor(core): remove unsafe type assertions in error utils (Phase 1.1) by @mattKorwel in - [#18870](https://github.com/google-gemini/gemini-cli/pull/18870) -- Show notification when there's a conflict with an extensions command by - @chrstnb in [#17890](https://github.com/google-gemini/gemini-cli/pull/17890) -- fix(cli): dismiss '?' shortcuts help on hotkeys and active states by - @LyalinDotCom in - [#18583](https://github.com/google-gemini/gemini-cli/pull/18583) -- fix(core): prioritize conditional policy rules and harden Plan Mode by - @Abhijit-2592 in - [#18882](https://github.com/google-gemini/gemini-cli/pull/18882) -- feat(core): refine Plan Mode system prompt for agentic execution by + [#19750](https://github.com/google-gemini/gemini-cli/pull/19750) +- Disallow unsafe returns. by @gundermanc in + [#19767](https://github.com/google-gemini/gemini-cli/pull/19767) +- fix(cli): filter subagent sessions from resume history by @abhipatel12 in + [#19698](https://github.com/google-gemini/gemini-cli/pull/19698) +- chore(lint): fix lint errors seen when running npm run lint by @abhipatel12 in + [#19844](https://github.com/google-gemini/gemini-cli/pull/19844) +- feat(core): remove unnecessary login verbiage from Code Assist auth by @NTaylorMullen in - [#18799](https://github.com/google-gemini/gemini-cli/pull/18799) -- feat(plan): create metrics for usage of `AskUser` tool by @Adib234 in - [#18820](https://github.com/google-gemini/gemini-cli/pull/18820) -- feat(cli): support Ctrl-Z suspension by @scidomino in - [#18931](https://github.com/google-gemini/gemini-cli/pull/18931) -- fix(github-actions): use robot PAT for release creation to trigger release - notes by @SandyTao520 in - [#18794](https://github.com/google-gemini/gemini-cli/pull/18794) -- feat: add strict seatbelt profiles and remove unusable closed profiles by + [#19861](https://github.com/google-gemini/gemini-cli/pull/19861) +- fix(plan): time share by approval mode dashboard reporting negative time + shares by @Adib234 in + [#19847](https://github.com/google-gemini/gemini-cli/pull/19847) +- fix(core): allow any preview model in quota access check by @bdmorgan in + [#19867](https://github.com/google-gemini/gemini-cli/pull/19867) +- fix(core): prevent omission placeholder deletions in replace/write_file by + @nsalerni in [#19870](https://github.com/google-gemini/gemini-cli/pull/19870) +- fix(core): add uniqueness guard to edit tool by @Shivangisharma4 in + [#19890](https://github.com/google-gemini/gemini-cli/pull/19890) +- refactor(config): remove enablePromptCompletion from settings by @sehoon38 in + [#19974](https://github.com/google-gemini/gemini-cli/pull/19974) +- refactor(core): move session conversion logic to core by @abhipatel12 in + [#19972](https://github.com/google-gemini/gemini-cli/pull/19972) +- Fix: Persist manual model selection on restart #19864 by @Nixxx19 in + [#19891](https://github.com/google-gemini/gemini-cli/pull/19891) +- fix(core): increase default retry attempts and add quota error backoff by + @sehoon38 in [#19949](https://github.com/google-gemini/gemini-cli/pull/19949) +- feat(core): add policy chain support for Gemini 3.1 by @sehoon38 in + [#19991](https://github.com/google-gemini/gemini-cli/pull/19991) +- Updates command reference and /stats command. by @g-samroberts in + [#19794](https://github.com/google-gemini/gemini-cli/pull/19794) +- Fix for silent failures in non-interactive mode by @owenofbrien in + [#19905](https://github.com/google-gemini/gemini-cli/pull/19905) +- fix(plan): allow plan mode writes on Windows and fix prompt paths by @Adib234 + in [#19658](https://github.com/google-gemini/gemini-cli/pull/19658) +- fix(core): prevent OAuth server crash on unexpected requests by @reyyanxahmed + in [#19668](https://github.com/google-gemini/gemini-cli/pull/19668) +- feat: Map tool kinds to explicit ACP.ToolKind values and update test … by + @sripasg in [#19547](https://github.com/google-gemini/gemini-cli/pull/19547) +- chore: restrict gemini-automted-issue-triage to only allow echo by @galz10 in + [#20047](https://github.com/google-gemini/gemini-cli/pull/20047) +- Allow ask headers longer than 16 chars by @scidomino in + [#20041](https://github.com/google-gemini/gemini-cli/pull/20041) +- fix(core): prevent state corruption in McpClientManager during collis by @h30s + in [#19782](https://github.com/google-gemini/gemini-cli/pull/19782) +- fix(bundling): copy devtools package to bundle for runtime resolution by @SandyTao520 in - [#18876](https://github.com/google-gemini/gemini-cli/pull/18876) -- chore: cleanup unused and add unlisted dependencies in packages/a2a-server by - @adamfweidman in - [#18916](https://github.com/google-gemini/gemini-cli/pull/18916) -- fix(plan): isolate plan files per session by @Adib234 in - [#18757](https://github.com/google-gemini/gemini-cli/pull/18757) -- fix: character truncation in raw markdown mode by @jackwotherspoon in - [#18938](https://github.com/google-gemini/gemini-cli/pull/18938) -- feat(cli): prototype clean UI toggle and minimal-mode bleed-through by - @LyalinDotCom in - [#18683](https://github.com/google-gemini/gemini-cli/pull/18683) -- ui(polish) blend background color with theme by @jacob314 in - [#18802](https://github.com/google-gemini/gemini-cli/pull/18802) -- Add generic searchable list to back settings and extensions by @chrstnb in - [#18838](https://github.com/google-gemini/gemini-cli/pull/18838) -- feat(ui): align `AskUser` color scheme with UX spec by @jerop in - [#18943](https://github.com/google-gemini/gemini-cli/pull/18943) -- Hide AskUser tool validation errors from UI (agent self-corrects) by @jerop in - [#18954](https://github.com/google-gemini/gemini-cli/pull/18954) -- bug(cli) fix flicker due to AppContainer continuous initialization by - @jacob314 in [#18958](https://github.com/google-gemini/gemini-cli/pull/18958) -- feat(admin): Add admin controls documentation by @skeshive in - [#18644](https://github.com/google-gemini/gemini-cli/pull/18644) -- feat(cli): disable ctrl-s shortcut outside of alternate buffer mode by - @jacob314 in [#18887](https://github.com/google-gemini/gemini-cli/pull/18887) -- fix(vim): vim support that feels (more) complete by @ppgranger in - [#18755](https://github.com/google-gemini/gemini-cli/pull/18755) -- feat(policy): add --policy flag for user defined policies by @allenhutchison - in [#18500](https://github.com/google-gemini/gemini-cli/pull/18500) -- Update installation guide by @g-samroberts in - [#18823](https://github.com/google-gemini/gemini-cli/pull/18823) -- refactor(core): centralize tool definitions (Group 1: replace, search, grep) - by @aishaneeshah in - [#18944](https://github.com/google-gemini/gemini-cli/pull/18944) -- refactor(cli): finalize event-driven transition and remove interaction bridge - by @abhipatel12 in - [#18569](https://github.com/google-gemini/gemini-cli/pull/18569) -- Fix drag and drop escaping by @scidomino in - [#18965](https://github.com/google-gemini/gemini-cli/pull/18965) -- feat(sdk): initial package bootstrap for SDK by @mbleigh in - [#18861](https://github.com/google-gemini/gemini-cli/pull/18861) -- feat(sdk): implements SessionContext for SDK tool calls by @mbleigh in - [#18862](https://github.com/google-gemini/gemini-cli/pull/18862) -- fix(plan): make question type required in AskUser tool by @Adib234 in - [#18959](https://github.com/google-gemini/gemini-cli/pull/18959) -- fix(core): ensure --yolo does not force headless mode by @NTaylorMullen in - [#18976](https://github.com/google-gemini/gemini-cli/pull/18976) -- refactor(core): adopt `CoreToolCallStatus` enum for type safety by @jerop in - [#18998](https://github.com/google-gemini/gemini-cli/pull/18998) -- Enable in-CLI extension management commands for team by @chrstnb in - [#18957](https://github.com/google-gemini/gemini-cli/pull/18957) -- Adjust lint rules to avoid unnecessary warning. by @scidomino in - [#18970](https://github.com/google-gemini/gemini-cli/pull/18970) -- fix(vscode): resolve unsafe type assertion lint errors by @ehedlund in - [#19006](https://github.com/google-gemini/gemini-cli/pull/19006) -- Remove unnecessary eslint config file by @scidomino in - [#19015](https://github.com/google-gemini/gemini-cli/pull/19015) -- fix(core): Prevent loop detection false positives on lists with long shared - prefixes by @SandyTao520 in - [#18975](https://github.com/google-gemini/gemini-cli/pull/18975) -- feat(core): fallback to chat-base when using unrecognized models for chat by - @SandyTao520 in - [#19016](https://github.com/google-gemini/gemini-cli/pull/19016) -- docs: fix inconsistent commandRegex example in policy engine by @NTaylorMullen - in [#19027](https://github.com/google-gemini/gemini-cli/pull/19027) -- fix(plan): persist the approval mode in UI even when agent is thinking by - @Adib234 in [#18955](https://github.com/google-gemini/gemini-cli/pull/18955) -- feat(sdk): Implement dynamic system instructions by @mbleigh in - [#18863](https://github.com/google-gemini/gemini-cli/pull/18863) -- Docs: Refresh docs to organize and standardize reference materials. by - @jkcinouye in [#18403](https://github.com/google-gemini/gemini-cli/pull/18403) -- fix windows escaping (and broken tests) by @scidomino in - [#19011](https://github.com/google-gemini/gemini-cli/pull/19011) -- refactor: use `CoreToolCallStatus` in the the history data model by @jerop in - [#19033](https://github.com/google-gemini/gemini-cli/pull/19033) -- feat(cleanup): enable 30-day session retention by default by @skeshive in - [#18854](https://github.com/google-gemini/gemini-cli/pull/18854) -- feat(plan): hide plan write and edit operations on plans in Plan Mode by - @jerop in [#19012](https://github.com/google-gemini/gemini-cli/pull/19012) -- bug(ui) fix flicker refreshing background color by @jacob314 in - [#19041](https://github.com/google-gemini/gemini-cli/pull/19041) -- chore: fix dep vulnerabilities by @scidomino in - [#19036](https://github.com/google-gemini/gemini-cli/pull/19036) -- Revamp automated changelog skill by @g-samroberts in - [#18974](https://github.com/google-gemini/gemini-cli/pull/18974) -- feat(sdk): implement support for custom skills by @mbleigh in - [#19031](https://github.com/google-gemini/gemini-cli/pull/19031) -- refactor(core): complete centralization of core tool definitions by + [#19766](https://github.com/google-gemini/gemini-cli/pull/19766) +- feat(policy): Support MCP Server Wildcards in Policy Engine by @jerop in + [#20024](https://github.com/google-gemini/gemini-cli/pull/20024) +- docs(CONTRIBUTING): update React DevTools version to 6 by @mmgok in + [#20014](https://github.com/google-gemini/gemini-cli/pull/20014) +- feat(core): optimize tool descriptions and schemas for Gemini 3 by @aishaneeshah in - [#18991](https://github.com/google-gemini/gemini-cli/pull/18991) -- feat: add /commands reload to refresh custom TOML commands by @korade-krushna - in [#19078](https://github.com/google-gemini/gemini-cli/pull/19078) -- fix(cli): wrap terminal capability queries in hidden sequence by @srithreepo - in [#19080](https://github.com/google-gemini/gemini-cli/pull/19080) -- fix(workflows): fix GitHub App token permissions for maintainer detection by - @bdmorgan in [#19139](https://github.com/google-gemini/gemini-cli/pull/19139) -- test: fix hook integration test flakiness on Windows CI by @NTaylorMullen in - [#18665](https://github.com/google-gemini/gemini-cli/pull/18665) -- fix(core): Encourage non-interactive flags for scaffolding commands by - @NTaylorMullen in - [#18804](https://github.com/google-gemini/gemini-cli/pull/18804) -- fix(core): propagate User-Agent header to setup-phase CodeAssist API calls by - @gsquared94 in - [#19182](https://github.com/google-gemini/gemini-cli/pull/19182) -- docs: document .agents/skills alias and discovery precedence by @kevmoo in - [#19166](https://github.com/google-gemini/gemini-cli/pull/19166) -- feat(cli): add loading state to new agents notification by @sehoon38 in - [#19190](https://github.com/google-gemini/gemini-cli/pull/19190) -- Add base branch to workflow. by @g-samroberts in - [#19189](https://github.com/google-gemini/gemini-cli/pull/19189) -- feat(cli): handle invalid model names in useQuotaAndFallback by @sehoon38 in - [#19222](https://github.com/google-gemini/gemini-cli/pull/19222) -- docs: custom themes in extensions by @jackwotherspoon in - [#19219](https://github.com/google-gemini/gemini-cli/pull/19219) -- Disable workspace settings when starting GCLI in the home directory. by - @kevinjwang1 in - [#19034](https://github.com/google-gemini/gemini-cli/pull/19034) -- feat(cli): refactor model command to support set and manage subcommands by - @sehoon38 in [#19221](https://github.com/google-gemini/gemini-cli/pull/19221) -- Add refresh/reload aliases to slash command subcommands by @korade-krushna in - [#19218](https://github.com/google-gemini/gemini-cli/pull/19218) -- refactor: consolidate development rules and add cli guidelines by @jacob314 in - [#19214](https://github.com/google-gemini/gemini-cli/pull/19214) -- chore(ui): remove outdated tip about model routing by @sehoon38 in - [#19226](https://github.com/google-gemini/gemini-cli/pull/19226) -- feat(core): support custom reasoning models by default by @NTaylorMullen in - [#19227](https://github.com/google-gemini/gemini-cli/pull/19227) -- Add Solarized Dark and Solarized Light themes by @rmedranollamas in - [#19064](https://github.com/google-gemini/gemini-cli/pull/19064) -- fix(telemetry): replace JSON.stringify with safeJsonStringify in file - exporters by @gsquared94 in - [#19244](https://github.com/google-gemini/gemini-cli/pull/19244) -- feat(telemetry): add keychain availability and token storage metrics by - @abhipatel12 in - [#18971](https://github.com/google-gemini/gemini-cli/pull/18971) -- feat(cli): update approval mode cycle order by @jerop in - [#19254](https://github.com/google-gemini/gemini-cli/pull/19254) -- refactor(cli): code review cleanup fix for tab+tab by @jacob314 in - [#18967](https://github.com/google-gemini/gemini-cli/pull/18967) -- feat(plan): support project exploration without planning when in plan mode by - @Adib234 in [#18992](https://github.com/google-gemini/gemini-cli/pull/18992) -- feat: add role-specific statistics to telemetry and UI (cont. #15234) by - @yunaseoul in [#18824](https://github.com/google-gemini/gemini-cli/pull/18824) -- feat(cli): remove Plan Mode from rotation when actively working by @jerop in - [#19262](https://github.com/google-gemini/gemini-cli/pull/19262) -- Fix side breakage where anchors don't work in slugs. by @g-samroberts in - [#19261](https://github.com/google-gemini/gemini-cli/pull/19261) -- feat(config): add setting to make directory tree context configurable by - @kevin-ramdass in - [#19053](https://github.com/google-gemini/gemini-cli/pull/19053) -- fix(acp): Wait for mcp initialization in acp (#18893) by @Mervap in - [#18894](https://github.com/google-gemini/gemini-cli/pull/18894) -- docs: format UTC times in releases doc by @pavan-sh in - [#18169](https://github.com/google-gemini/gemini-cli/pull/18169) -- Docs: Clarify extensions documentation. by @jkcinouye in - [#19277](https://github.com/google-gemini/gemini-cli/pull/19277) -- refactor(core): modularize tool definitions by model family by @aishaneeshah - in [#19269](https://github.com/google-gemini/gemini-cli/pull/19269) -- fix(paths): Add cross-platform path normalization by @spencer426 in - [#18939](https://github.com/google-gemini/gemini-cli/pull/18939) -- feat(core): experimental in-progress steering hints (1 of 3) by @joshualitt in - [#19008](https://github.com/google-gemini/gemini-cli/pull/19008) + [#19643](https://github.com/google-gemini/gemini-cli/pull/19643) +- feat(core): implement experimental direct web fetch by @mbleigh in + [#19557](https://github.com/google-gemini/gemini-cli/pull/19557) +- feat(core): replace expected_replacements with allow_multiple in replace tool + by @SandyTao520 in + [#20033](https://github.com/google-gemini/gemini-cli/pull/20033) +- fix(sandbox): harden image packaging integrity checks by @aviralgarg05 in + [#19552](https://github.com/google-gemini/gemini-cli/pull/19552) +- fix(core): allow environment variable expansion and explicit overrides for MCP + servers by @galz10 in + [#18837](https://github.com/google-gemini/gemini-cli/pull/18837) +- feat(policy): Implement Tool Annotation Matching in Policy Engine by @jerop in + [#20029](https://github.com/google-gemini/gemini-cli/pull/20029) +- fix(core): prevent utility calls from changing session active model by + @adamfweidman in + [#20035](https://github.com/google-gemini/gemini-cli/pull/20035) +- fix(cli): skip workspace policy loading when in home directory by + @Abhijit-2592 in + [#20054](https://github.com/google-gemini/gemini-cli/pull/20054) +- fix(scripts): Add Windows (win32/x64) support to lint.js by @ZafeerMahmood in + [#16193](https://github.com/google-gemini/gemini-cli/pull/16193) +- fix(a2a-server): Remove unsafe type assertions in agent by @Nixxx19 in + [#19723](https://github.com/google-gemini/gemini-cli/pull/19723) +- Fix: Handle corrupted token file gracefully when switching auth types (#19845) + by @Nixxx19 in + [#19850](https://github.com/google-gemini/gemini-cli/pull/19850) +- fix critical dep vulnerability by @scidomino in + [#20087](https://github.com/google-gemini/gemini-cli/pull/20087) +- Add new setting to configure maxRetries by @kevinjwang1 in + [#20064](https://github.com/google-gemini/gemini-cli/pull/20064) +- Stabilize tests. by @gundermanc in + [#20095](https://github.com/google-gemini/gemini-cli/pull/20095) +- make windows tests mandatory by @scidomino in + [#20096](https://github.com/google-gemini/gemini-cli/pull/20096) +- Add 3.1 pro preview to behavioral evals. by @gundermanc in + [#20088](https://github.com/google-gemini/gemini-cli/pull/20088) +- feat:PR-rate-limit by @JagjeevanAK in + [#19804](https://github.com/google-gemini/gemini-cli/pull/19804) +- feat(cli): allow expanding full details of MCP tool on approval by @y-okt in + [#19916](https://github.com/google-gemini/gemini-cli/pull/19916) +- feat(security): Introduce Conseca framework by @shrishabh in + [#13193](https://github.com/google-gemini/gemini-cli/pull/13193) +- fix(cli): Remove unsafe type assertions in activityLogger #19713 by @Nixxx19 + in [#19745](https://github.com/google-gemini/gemini-cli/pull/19745) +- feat: implement AfterTool tail tool calls by @googlestrobe in + [#18486](https://github.com/google-gemini/gemini-cli/pull/18486) +- ci(actions): fix PR rate limiter excluding maintainers by @scidomino in + [#20117](https://github.com/google-gemini/gemini-cli/pull/20117) +- Shortcuts: Move SectionHeader title below top line and refine styling by + @keithguerin in + [#18721](https://github.com/google-gemini/gemini-cli/pull/18721) +- refactor(ui): Update and simplify use of gray colors in themes by @keithguerin + in [#20141](https://github.com/google-gemini/gemini-cli/pull/20141) +- fix punycode2 by @jacob314 in + [#20154](https://github.com/google-gemini/gemini-cli/pull/20154) +- feat(ide): add GEMINI_CLI_IDE_PID env var to override IDE process detection by + @kiryltech in [#15842](https://github.com/google-gemini/gemini-cli/pull/15842) +- feat(policy): Propagate Tool Annotations for MCP Servers by @jerop in + [#20083](https://github.com/google-gemini/gemini-cli/pull/20083) +- fix(a2a-server): pass allowedTools settings to core Config by @reyyanxahmed in + [#19680](https://github.com/google-gemini/gemini-cli/pull/19680) +- feat(mcp): add progress bar, throttling, and input validation for MCP tool + progress by @jasmeetsb in + [#19772](https://github.com/google-gemini/gemini-cli/pull/19772) +- feat(policy): centralize plan mode tool visibility in policy engine by @jerop + in [#20178](https://github.com/google-gemini/gemini-cli/pull/20178) +- feat(browser): implement experimental browser agent by @gsquared94 in + [#19284](https://github.com/google-gemini/gemini-cli/pull/19284) +- feat(plan): summarize work after executing a plan by @jerop in + [#19432](https://github.com/google-gemini/gemini-cli/pull/19432) +- fix(core): create new McpClient on restart to apply updated config by @h30s in + [#20126](https://github.com/google-gemini/gemini-cli/pull/20126) +- Changelog for v0.30.0-preview.5 by @gemini-cli-robot in + [#20107](https://github.com/google-gemini/gemini-cli/pull/20107) +- Update packages. by @jacob314 in + [#20152](https://github.com/google-gemini/gemini-cli/pull/20152) +- Fix extension env dir loading issue by @chrstnb in + [#20198](https://github.com/google-gemini/gemini-cli/pull/20198) +- restrict /assign to help-wanted issues by @scidomino in + [#20207](https://github.com/google-gemini/gemini-cli/pull/20207) +- feat(plan): inject message when user manually exits Plan mode by @jerop in + [#20203](https://github.com/google-gemini/gemini-cli/pull/20203) +- feat(extensions): enforce folder trust for local extension install by @galz10 + in [#19703](https://github.com/google-gemini/gemini-cli/pull/19703) +- feat(hooks): adds support for RuntimeHook functions. by @mbleigh in + [#19598](https://github.com/google-gemini/gemini-cli/pull/19598) +- Docs: Update UI links. by @jkcinouye in + [#20224](https://github.com/google-gemini/gemini-cli/pull/20224) +- feat: prompt users to run /terminal-setup with yes/no by @ishaanxgupta in + [#16235](https://github.com/google-gemini/gemini-cli/pull/16235) +- fix: additional high vulnerabilities (minimatch, cross-spawn) by @adamfweidman + in [#20221](https://github.com/google-gemini/gemini-cli/pull/20221) +- feat(telemetry): Add context breakdown to API response event by @SandyTao520 + in [#19699](https://github.com/google-gemini/gemini-cli/pull/19699) +- Docs: Add nested sub-folders for related topics by @g-samroberts in + [#20235](https://github.com/google-gemini/gemini-cli/pull/20235) +- feat(plan): support automatic model switching for Plan Mode by @jerop in + [#20240](https://github.com/google-gemini/gemini-cli/pull/20240) -**Full changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.5 +**Full Changelog**: +https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.1 diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index f8ff24bed6..c1599df69e 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -5,18 +5,18 @@ and parameters. ## CLI commands -| Command | Description | Example | -| ---------------------------------- | ---------------------------------- | --------------------------------------------------- | -| `gemini` | Start interactive REPL | `gemini` | -| `gemini "query"` | Query non-interactively, then exit | `gemini "explain this project"` | -| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini` | -| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` | -| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` | -| `gemini -r "latest" "query"` | Continue session with a new prompt | `gemini -r "latest" "Check for type errors"` | -| `gemini -r "" "query"` | Resume session by ID | `gemini -r "abc123" "Finish this PR"` | -| `gemini update` | Update to latest version | `gemini update` | -| `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) | -| `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) | +| Command | Description | Example | +| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ | +| `gemini` | Start interactive REPL | `gemini` | +| `gemini "query"` | Query non-interactively, then exit | `gemini "explain this project"` | +| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`
`Get-Content logs.txt \| gemini` | +| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` | +| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` | +| `gemini -r "latest" "query"` | Continue session with a new prompt | `gemini -r "latest" "Check for type errors"` | +| `gemini -r "" "query"` | Resume session by ID | `gemini -r "abc123" "Finish this PR"` | +| `gemini update` | Update to latest version | `gemini update` | +| `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) | +| `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) | ### Positional arguments diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index e84839b8a3..dd2698290e 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -278,11 +278,20 @@ Let's create a global command that asks the model to refactor a piece of code. First, ensure the user commands directory exists, then create a `refactor` subdirectory for organization and the final TOML file. +**macOS/Linux** + ```bash mkdir -p ~/.gemini/commands/refactor touch ~/.gemini/commands/refactor/pure.toml ``` +**Windows (PowerShell)** + +```powershell +New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\commands\refactor" +New-Item -ItemType File -Force -Path "$env:USERPROFILE\.gemini\commands\refactor\pure.toml" +``` + **2. Add the content to the file:** Open `~/.gemini/commands/refactor/pure.toml` in your editor and add the diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index b6d469755b..44d8ba9467 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -203,6 +203,15 @@ with the actual Gemini CLI process, which inherits the environment variable. This makes it significantly more difficult for a user to bypass the enforced settings. +**PowerShell Profile (Windows alternative):** + +On Windows, administrators can achieve similar results by adding the environment +variable to the system-wide or user-specific PowerShell profile: + +```powershell +Add-Content -Path $PROFILE -Value '$env:GEMINI_CLI_SYSTEM_SETTINGS_PATH="C:\ProgramData\gemini-cli\settings.json"' +``` + ## User isolation in shared environments In shared compute environments (like ML experiment runners or shared build @@ -214,12 +223,22 @@ use the `GEMINI_CLI_HOME` environment variable to point to a unique directory for a specific user or job. The CLI will create a `.gemini` folder inside the specified path. +**macOS/Linux** + ```bash # Isolate state for a specific job export GEMINI_CLI_HOME="/tmp/gemini-job-123" gemini ``` +**Windows (PowerShell)** + +```powershell +# Isolate state for a specific job +$env:GEMINI_CLI_HOME="C:\temp\gemini-job-123" +gemini +``` + ## Restricting tool access You can significantly enhance security by controlling which tools the Gemini diff --git a/docs/cli/model.md b/docs/cli/model.md index 62bfcf5b0b..3da5ea4cbc 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -19,24 +19,15 @@ Use the following command in Gemini CLI: Running this command will open a dialog with your options: -| Option | Description | Models | -| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- | -| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) | -| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash | -| Manual | Select a specific model. | Any available model. | +| Option | Description | Models | +| ----------------- | -------------------------------------------------------------- | -------------------------------------------- | +| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview, gemini-3-flash-preview | +| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash | +| Manual | Select a specific model. | Any available model. | We recommend selecting one of the above **Auto** options. However, you can select **Manual** to select a specific model from those available. -### Gemini 3 and preview features - -> **Note:** Gemini 3 is not currently available on all account types. To learn -> more about Gemini 3 access, refer to -> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md). - -To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable -[**Preview Features** by using the `settings` command](../cli/settings.md). - You can also use the `--model` flag to specify a particular Gemini model on startup. For more details, refer to the [configuration documentation](../reference/configuration.md). diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index ef41631302..03dd92967f 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -80,18 +80,37 @@ manually during a session. ### Planning Workflow +Plan Mode uses an adaptive planning workflow where the research depth, plan +structure, and consultation level are proportional to the task's complexity: + 1. **Explore & Analyze:** Analyze requirements and use read-only tools to map - the codebase and validate assumptions. For complex tasks, identify at least - two viable implementation approaches. -2. **Consult:** Present a summary of the identified approaches via [`ask_user`] - to obtain a selection. For simple or canonical tasks, this step may be - skipped. -3. **Draft:** Once an approach is selected, write a detailed implementation - plan to the plans directory. + affected modules and identify dependencies. +2. **Consult:** The depth of consultation is proportional to the task's + complexity: + - **Simple Tasks:** Proceed directly to drafting. + - **Standard Tasks:** Present a summary of viable approaches via + [`ask_user`] for selection. + - **Complex Tasks:** Present detailed trade-offs for at least two viable + approaches via [`ask_user`] and obtain approval before drafting. +3. **Draft:** Write a detailed implementation plan to the + [plans directory](#custom-plan-directory-and-policies). The plan's structure + adapts to the task: + - **Simple Tasks:** Focused on specific **Changes** and **Verification** + steps. + - **Standard Tasks:** Includes an **Objective**, **Key Files & Context**, + **Implementation Steps**, and **Verification & Testing**. + - **Complex Tasks:** Comprehensive plans including **Background & + Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives + Considered**, a phased **Implementation Plan**, **Verification**, and + **Migration & Rollback** strategies. 4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan and formally request approval. - **Approve:** Exit Plan Mode and start implementation. - **Iterate:** Provide feedback to refine the plan. + - **Refine manually:** Press **Ctrl + X** to open the plan file in your + [preferred external editor]. This allows you to manually refine the plan + steps before approval. The CLI will automatically refresh and show the + updated plan after you save and close the editor. For more complex or specialized planning tasks, you can [customize the planning workflow with skills](#customizing-planning-with-skills). @@ -119,6 +138,7 @@ These are the only allowed tools: - **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md` files in the `~/.gemini/tmp///plans/` directory or your [custom plans directory](#custom-plan-directory-and-policies). +- **Memory:** [`save_memory`] - **Skills:** [`activate_skill`] (allows loading specialized instructions and resources in a read-only manner) @@ -277,6 +297,7 @@ performance. You can disable this automatic switching in your settings: [`google_web_search`]: /docs/tools/web-search.md [`replace`]: /docs/tools/file-system.md#6-replace-edit [MCP tools]: /docs/tools/mcp-server.md +[`save_memory`]: /docs/tools/memory.md [`activate_skill`]: /docs/cli/skills.md [subagents]: /docs/core/subagents.md [policy engine]: /docs/reference/policy-engine.md @@ -288,3 +309,4 @@ performance. You can disable this automatic switching in your settings: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml [auto model]: /docs/reference/configuration.md#model-settings [model routing]: /docs/cli/telemetry.md#model-routing +[preferred external editor]: /docs/reference/configuration.md#general diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 392c71a176..1d075989af 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -55,12 +55,27 @@ from your organization's registry. ```bash # Enable sandboxing with command flag gemini -s -p "analyze the code structure" +``` -# Use environment variable +**Use environment variable** + +**macOS/Linux** + +```bash export GEMINI_SANDBOX=true gemini -p "run the test suite" +``` -# Configure in settings.json +**Windows (PowerShell)** + +```powershell +$env:GEMINI_SANDBOX="true" +gemini -p "run the test suite" +``` + +**Configure in settings.json** + +```json { "tools": { "sandbox": "docker" @@ -99,26 +114,51 @@ use cases. To disable SELinux labeling for volume mounts, you can set the following: +**macOS/Linux** + ```bash export SANDBOX_FLAGS="--security-opt label=disable" ``` +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_FLAGS="--security-opt label=disable" +``` + Multiple flags can be provided as a space-separated string: +**macOS/Linux** + ```bash export SANDBOX_FLAGS="--flag1 --flag2=value" ``` +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_FLAGS="--flag1 --flag2=value" +``` + ## Linux UID/GID handling The sandbox automatically handles user permissions on Linux. Override these permissions with: +**macOS/Linux** + ```bash export SANDBOX_SET_UID_GID=true # Force host UID/GID export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping ``` +**Windows (PowerShell)** + +```powershell +$env:SANDBOX_SET_UID_GID="true" # Force host UID/GID +$env:SANDBOX_SET_UID_GID="false" # Disable UID/GID mapping +``` + ## Troubleshooting ### Common issues diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 8adccba6ae..faf3fca3f0 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -72,6 +72,7 @@ they appear in the UI. | Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | | Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | | Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` | +| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | | Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | ### IDE @@ -80,6 +81,12 @@ they appear in the UI. | -------- | ------------- | ---------------------------- | ------- | | IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` | +### Billing + +| UI Label | Setting | Description | Default | +| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` | + ### Model | UI Label | Setting | Description | Default | @@ -140,6 +147,7 @@ they appear in the UI. | Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` | | 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` | +| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` | ### Skills diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index b04d2e0173..c812d37965 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -103,23 +103,52 @@ Before using either method below, complete these steps: 1. Set your Google Cloud project ID: - For telemetry in a separate project from inference: + + **macOS/Linux** + ```bash export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" ``` + + **Windows (PowerShell)** + + ```powershell + $env:OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" + ``` + - For telemetry in the same project as inference: + + **macOS/Linux** + ```bash export GOOGLE_CLOUD_PROJECT="your-project-id" ``` + **Windows (PowerShell)** + + ```powershell + $env:GOOGLE_CLOUD_PROJECT="your-project-id" + ``` + 2. Authenticate with Google Cloud: - If using a user account: ```bash gcloud auth application-default login ``` - If using a service account: + + **macOS/Linux** + ```bash export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" ``` + + **Windows (PowerShell)** + + ```powershell + $env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\service-account.json" + ``` + 3. Make sure your account or service account has these IAM roles: - Cloud Trace Agent - Monitoring Metric Writer @@ -176,11 +205,12 @@ Sends telemetry directly to Google Cloud services. No collector needed. } ``` 2. Run Gemini CLI and send prompts. -3. View logs and metrics: +3. View logs, metrics, and traces: - Open the Google Cloud Console in your browser after sending prompts: - - Logs: https://console.cloud.google.com/logs/ - - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer - - Traces: https://console.cloud.google.com/traces/list + - Logs (Logs Explorer): https://console.cloud.google.com/logs/ + - Metrics (Metrics Explorer): + https://console.cloud.google.com/monitoring/metrics-explorer + - Traces (Trace Explorer): https://console.cloud.google.com/traces/list ### Collector-based export (advanced) @@ -208,11 +238,12 @@ forward data to Google Cloud. - Save collector logs to `~/.gemini/tmp//otel/collector-gcp.log` - Stop collector on exit (e.g. `Ctrl+C`) 3. Run Gemini CLI and send prompts. -4. View logs and metrics: +4. View logs, metrics, and traces: - Open the Google Cloud Console in your browser after sending prompts: - - Logs: https://console.cloud.google.com/logs/ - - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer - - Traces: https://console.cloud.google.com/traces/list + - Logs (Logs Explorer): https://console.cloud.google.com/logs/ + - Metrics (Metrics Explorer): + https://console.cloud.google.com/monitoring/metrics-explorer + - Traces (Trace Explorer): https://console.cloud.google.com/traces/list - Open `~/.gemini/tmp//otel/collector-gcp.log` to view local collector logs. @@ -270,10 +301,10 @@ For local development and debugging, you can capture telemetry data locally: 3. View traces at http://localhost:16686 and logs/metrics in the collector log file. -## Logs and metrics +## Logs, metrics, and traces -The following section describes the structure of logs and metrics generated for -Gemini CLI. +The following section describes the structure of logs, metrics, and traces +generated for Gemini CLI. The `session.id`, `installation.id`, `active_approval_mode`, and `user.email` (available only when authenticated with a Google account) are included as common @@ -824,6 +855,32 @@ Optional performance monitoring for startup, CPU/memory, and phase timing. - `current_value` (number) - `baseline_value` (number) +### Traces + +Traces offer a granular, "under-the-hood" view of every agent and backend +operation. By providing a high-fidelity execution map, they enable precise +debugging of complex tool interactions and deep performance optimization. Each +trace captures rich, consistent metadata via custom span attributes: + +- `gen_ai.operation.name` (string): The high-level operation kind (e.g. + "tool_call", "llm_call"). +- `gen_ai.agent.name` (string): The service agent identifier ("gemini-cli"). +- `gen_ai.agent.description` (string): The service agent description. +- `gen_ai.input.messages` (string): Input messages or metadata specific to the + operation. +- `gen_ai.output.messages` (string): Output messages or metadata generated from + the operation. +- `gen_ai.request.model` (string): The request model name. +- `gen_ai.response.model` (string): The response model name. +- `gen_ai.system_instructions` (json string): The system instructions. +- `gen_ai.prompt.name` (string): The prompt name. +- `gen_ai.tool.name` (string): The executed tool's name. +- `gen_ai.tool.call_id` (string): The generated specific ID of the tool call. +- `gen_ai.tool.description` (string): The executed tool's description. +- `gen_ai.tool.definitions` (json string): The executed tool's description. +- `gen_ai.conversation.id` (string): The current CLI session ID. +- Additional user-defined Custom Attributes passed via the span's configuration. + #### GenAI semantic convention The following metrics comply with [OpenTelemetry GenAI semantic conventions] for diff --git a/docs/cli/tutorials/automation.md b/docs/cli/tutorials/automation.md index 11e489aff3..fb1d8d48d2 100644 --- a/docs/cli/tutorials/automation.md +++ b/docs/cli/tutorials/automation.md @@ -37,10 +37,18 @@ output. Pipe a file: +**macOS/Linux** + ```bash cat error.log | gemini "Explain why this failed" ``` +**Windows (PowerShell)** + +```powershell +Get-Content error.log | gemini "Explain why this failed" +``` + Pipe a command: ```bash @@ -57,7 +65,10 @@ results to a file. You have a folder of Python scripts and want to generate a `README.md` for each one. -1. Save the following code as `generate_docs.sh`: +1. Save the following code as `generate_docs.sh` (or `generate_docs.ps1` for + Windows): + + **macOS/Linux (`generate_docs.sh`)** ```bash #!/bin/bash @@ -72,13 +83,34 @@ one. done ``` + **Windows PowerShell (`generate_docs.ps1`)** + + ```powershell + # Loop through all Python files + Get-ChildItem -Filter *.py | ForEach-Object { + Write-Host "Generating docs for $($_.Name)..." + + $newName = $_.Name -replace '\.py$', '.md' + # Ask Gemini CLI to generate the documentation and print it to stdout + gemini "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8 + } + ``` + 2. Make the script executable and run it in your directory: + **macOS/Linux** + ```bash chmod +x generate_docs.sh ./generate_docs.sh ``` + **Windows (PowerShell)** + + ```powershell + .\generate_docs.ps1 + ``` + This creates a corresponding Markdown file for every Python file in the folder. @@ -90,7 +122,10 @@ like `jq`. To get pure JSON data from the model, combine the ### Scenario: Extract and return structured data -1. Save the following script as `generate_json.sh`: +1. Save the following script as `generate_json.sh` (or `generate_json.ps1` for + Windows): + + **macOS/Linux (`generate_json.sh`)** ```bash #!/bin/bash @@ -105,13 +140,35 @@ like `jq`. To get pure JSON data from the model, combine the gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | jq -r '.response' > data.json ``` -2. Run `generate_json.sh`: + **Windows PowerShell (`generate_json.ps1`)** + + ```powershell + # Ensure we are in a project root + if (-not (Test-Path "package.json")) { + Write-Error "Error: package.json not found." + exit 1 + } + + # Extract data (requires jq installed, or you can use ConvertFrom-Json) + $output = gemini --output-format json "Return a raw JSON object with keys 'version' and 'deps' from @package.json" | ConvertFrom-Json + $output.response | Out-File -FilePath data.json -Encoding utf8 + ``` + +2. Run the script: + + **macOS/Linux** ```bash chmod +x generate_json.sh ./generate_json.sh ``` + **Windows (PowerShell)** + + ```powershell + .\generate_json.ps1 + ``` + 3. Check `data.json`. The file should look like this: ```json @@ -129,8 +186,10 @@ Use headless mode to perform custom, automated AI tasks. ### Scenario: Create a "Smart Commit" alias -You can add a function to your shell configuration (like `.zshrc` or `.bashrc`) -to create a `git commit` wrapper that writes the message for you. +You can add a function to your shell configuration to create a `git commit` +wrapper that writes the message for you. + +**macOS/Linux (Bash/Zsh)** 1. Open your `.zshrc` file (or `.bashrc` if you use Bash) in your preferred text editor. @@ -170,6 +229,43 @@ to create a `git commit` wrapper that writes the message for you. source ~/.zshrc ``` +**Windows (PowerShell)** + +1. Open your PowerShell profile in your preferred text editor. + + ```powershell + notepad $PROFILE + ``` + +2. Scroll to the very bottom of the file and paste this code: + + ```powershell + function gcommit { + # Get the diff of staged changes + $diff = git diff --staged + + if (-not $diff) { + Write-Host "No staged changes to commit." + return + } + + # Ask Gemini to write the message + Write-Host "Generating commit message..." + $msg = $diff | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message." + + # Commit with the generated message + git commit -m "$msg" + } + ``` + + Save your file and exit. + +3. Run this command to make the function available immediately: + + ```powershell + . $PROFILE + ``` + 4. Use your new command: ```bash diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 48c94cd78d..03b6e56376 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -20,10 +20,18 @@ Most MCP servers require authentication. For GitHub, you need a PAT. **Read/Write** access to **Issues** and **Pull Requests**. 3. Store it in your environment: +**macOS/Linux** + ```bash export GITHUB_PERSONAL_ACCESS_TOKEN="github_pat_..." ``` +**Windows (PowerShell)** + +```powershell +$env:GITHUB_PERSONAL_ACCESS_TOKEN="github_pat_..." +``` + ## How to configure Gemini CLI You tell Gemini about new servers by editing your `settings.json`. diff --git a/docs/cli/tutorials/skills-getting-started.md b/docs/cli/tutorials/skills-getting-started.md index 2614a679ef..ee59641d21 100644 --- a/docs/cli/tutorials/skills-getting-started.md +++ b/docs/cli/tutorials/skills-getting-started.md @@ -14,10 +14,18 @@ responding correctly. 1. Run the following command to create the folders: + **macOS/Linux** + ```bash mkdir -p .gemini/skills/api-auditor/scripts ``` + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path ".gemini\skills\api-auditor\scripts" + ``` + ### Create the definition 1. Create a file at `.gemini/skills/api-auditor/SKILL.md`. This tells the agent diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index d36df94d78..2c2b730126 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -227,6 +227,42 @@ skill definitions in a `skills/` directory. For example, Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add agent definition files (`.md`) to an `agents/` directory in your extension root. +### Policy Engine + +Extensions can contribute policy rules and safety checkers to the Gemini CLI +[Policy Engine](../reference/policy-engine.md). These rules are defined in +`.toml` files and take effect when the extension is activated. + +To add policies, create a `policies/` directory in your extension's root and +place your `.toml` policy files inside it. Gemini CLI automatically loads all +`.toml` files from this directory. + +Rules contributed by extensions run in their own tier (tier 2), alongside +workspace-defined policies. This tier has higher priority than the default rules +but lower priority than user or admin policies. + +> **Warning:** For security, Gemini CLI ignores any `allow` decisions or `yolo` +> mode configurations in extension policies. This ensures that an extension +> cannot automatically approve tool calls or bypass security measures without +> your confirmation. + +**Example `policies.toml`** + +```toml +[[rule]] +toolName = "my_server__dangerous_tool" +decision = "ask_user" +priority = 100 + +[[safety_checker]] +toolName = "my_server__write_data" +priority = 200 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +required_context = ["environment"] +``` + ### Themes Extensions can provide custom themes to personalize the CLI UI. Themes are diff --git a/docs/extensions/writing-extensions.md b/docs/extensions/writing-extensions.md index 213d77542e..b22f69e672 100644 --- a/docs/extensions/writing-extensions.md +++ b/docs/extensions/writing-extensions.md @@ -189,10 +189,18 @@ Custom commands create shortcuts for complex prompts. 1. Create a `commands` directory and a subdirectory for your command group: + **macOS/Linux** + ```bash mkdir -p commands/fs ``` + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "commands\fs" + ``` + 2. Create a file named `commands/fs/grep-code.toml`: ```toml @@ -252,10 +260,18 @@ Skills are activated only when needed, which saves context tokens. 1. Create a `skills` directory and a subdirectory for your skill: + **macOS/Linux** + ```bash mkdir -p skills/security-audit ``` + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "skills\security-audit" + ``` + 2. Create a `skills/security-audit/SKILL.md` file: ```markdown diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index e8696137cf..61d4a5c040 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -78,11 +78,20 @@ To authenticate and use Gemini CLI with a Gemini API key: 2. Set the `GEMINI_API_KEY` environment variable to your key. For example: + **macOS/Linux** + ```bash # Replace YOUR_GEMINI_API_KEY with the key from AI Studio export GEMINI_API_KEY="YOUR_GEMINI_API_KEY" ``` + **Windows (PowerShell)** + + ```powershell + # Replace YOUR_GEMINI_API_KEY with the key from AI Studio + $env:GEMINI_API_KEY="YOUR_GEMINI_API_KEY" + ``` + To make this setting persistent, see [Persisting Environment Variables](#persisting-vars). @@ -114,12 +123,22 @@ or the location where you want to run your jobs. For example: +**macOS/Linux** + ```bash # Replace with your project ID and desired location (e.g., us-central1) export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` +**Windows (PowerShell)** + +```powershell +# Replace with your project ID and desired location (e.g., us-central1) +$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" +$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" +``` + To make any Vertex AI environment variable settings persistent, see [Persisting Environment Variables](#persisting-vars). @@ -130,9 +149,17 @@ Consider this authentication method if you have Google Cloud CLI installed. > **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you > must unset them to use ADC: > +> **macOS/Linux** +> > ```bash > unset GOOGLE_API_KEY GEMINI_API_KEY > ``` +> +> **Windows (PowerShell)** +> +> ```powershell +> Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +> ``` 1. Verify you have a Google Cloud project and Vertex AI API is enabled. @@ -160,9 +187,17 @@ pipelines, or if your organization restricts user-based ADC or API key creation. > **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you > must unset them: > +> **macOS/Linux** +> > ```bash > unset GOOGLE_API_KEY GEMINI_API_KEY > ``` +> +> **Windows (PowerShell)** +> +> ```powershell +> Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +> ``` 1. [Create a service account and key](https://cloud.google.com/iam/docs/keys-create-delete) and download the provided JSON file. Assign the "Vertex AI User" role to the @@ -171,11 +206,20 @@ pipelines, or if your organization restricts user-based ADC or API key creation. 2. Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the JSON file's absolute path. For example: + **macOS/Linux** + ```bash # Replace /path/to/your/keyfile.json with the actual path export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/keyfile.json" ``` + **Windows (PowerShell)** + + ```powershell + # Replace C:\path\to\your\keyfile.json with the actual path + $env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\keyfile.json" + ``` + 3. [Configure your Google Cloud Project](#set-gcp). 4. Start the CLI: @@ -195,11 +239,20 @@ pipelines, or if your organization restricts user-based ADC or API key creation. 2. Set the `GOOGLE_API_KEY` environment variable: + **macOS/Linux** + ```bash # Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" ``` + **Windows (PowerShell)** + + ```powershell + # Replace YOUR_GOOGLE_API_KEY with your Vertex AI API key + $env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" + ``` + > **Note:** If you see errors like > `"API keys are not supported by this API..."`, your organization might > restrict API key usage for this service. Try the other Vertex AI @@ -243,11 +296,20 @@ To configure Gemini CLI to use a Google Cloud project, do the following: For example, to set the `GOOGLE_CLOUD_PROJECT_ID` variable: + **macOS/Linux** + ```bash # Replace YOUR_PROJECT_ID with your actual Google Cloud project ID export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" ``` + **Windows (PowerShell)** + + ```powershell + # Replace YOUR_PROJECT_ID with your actual Google Cloud project ID + $env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" + ``` + To make this setting persistent, see [Persisting Environment Variables](#persisting-vars). @@ -257,16 +319,22 @@ To avoid setting environment variables for every terminal session, you can persist them with the following methods: 1. **Add your environment variables to your shell configuration file:** Append - the `export ...` commands to your shell's startup file (e.g., `~/.bashrc`, - `~/.zshrc`, or `~/.profile`) and reload your shell (e.g., - `source ~/.bashrc`). + the environment variable commands to your shell's startup file. + + **macOS/Linux** (e.g., `~/.bashrc`, `~/.zshrc`, or `~/.profile`): ```bash - # Example for .bashrc echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc source ~/.bashrc ``` + **Windows (PowerShell)** (e.g., `$PROFILE`): + + ```powershell + Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' + . $PROFILE + ``` + > **Warning:** Be aware that when you export API keys or service account > paths in your shell configuration file, any process launched from that > shell can read them. @@ -274,10 +342,13 @@ persist them with the following methods: 2. **Use a `.env` file:** Create a `.gemini/.env` file in your project directory or home directory. Gemini CLI automatically loads variables from the first `.env` file it finds, searching up from the current directory, - then in `~/.gemini/.env` or `~/.env`. `.gemini/.env` is recommended. + then in your home directory's `.gemini/.env` (e.g., `~/.gemini/.env` or + `%USERPROFILE%\.gemini\.env`). Example for user-wide settings: + **macOS/Linux** + ```bash mkdir -p ~/.gemini cat >> ~/.gemini/.env <<'EOF' @@ -286,6 +357,16 @@ persist them with the following methods: EOF ``` + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini" + @" + GOOGLE_CLOUD_PROJECT="your-project-id" + # Add other variables like GEMINI_API_KEY as needed + "@ | Out-File -FilePath "$env:USERPROFILE\.gemini\.env" -Encoding utf8 -Append + ``` + Variables are loaded from the first file found, not merged. ## Running in Google Cloud environments diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 1acf497659..c345584b69 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -1,6 +1,6 @@ # Gemini CLI installation, execution, and releases -This document provides an overview of Gemini CLI's sytem requriements, +This document provides an overview of Gemini CLI's system requirements, installation methods, and release types. ## Recommended system specifications @@ -13,7 +13,7 @@ installation methods, and release types. - "Casual" usage: 4GB+ RAM (short sessions, common tasks and edits) - "Power" usage: 16GB+ RAM (long sessions, large codebases, deep context) - **Runtime:** Node.js 20.0.0+ -- **Shell:** Bash or Zsh +- **Shell:** Bash, Zsh, or PowerShell - **Location:** [Gemini Code Assist supported locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas) - **Internet connection required** diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index fd80fc0b40..08dae0fdf8 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -167,6 +167,8 @@ try { Run hook scripts manually with sample JSON input to verify they behave as expected before hooking them up to the CLI. +**macOS/Linux** + ```bash # Create test input cat > test-input.json << 'EOF' @@ -187,7 +189,30 @@ cat test-input.json | .gemini/hooks/my-hook.sh # Check exit code echo "Exit code: $?" +``` +**Windows (PowerShell)** + +```powershell +# Create test input +@" +{ + "session_id": "test-123", + "cwd": "C:\\temp\\test", + "hook_event_name": "BeforeTool", + "tool_name": "write_file", + "tool_input": { + "file_path": "test.txt", + "content": "Test content" + } +} +"@ | Out-File -FilePath test-input.json -Encoding utf8 + +# Test the hook +Get-Content test-input.json | .\.gemini\hooks\my-hook.ps1 + +# Check exit code +Write-Host "Exit code: $LASTEXITCODE" ``` ### Check exit codes @@ -333,7 +358,7 @@ tool_name=$(echo "$input" | jq -r '.tool_name') ### Make scripts executable -Always make hook scripts executable: +Always make hook scripts executable on macOS/Linux: ```bash chmod +x .gemini/hooks/*.sh @@ -341,6 +366,10 @@ chmod +x .gemini/hooks/*.js ``` +**Windows Note**: On Windows, PowerShell scripts (`.ps1`) don't use `chmod`, but +you may need to ensure your execution policy allows them to run (e.g., +`Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`). + ### Version control Commit hooks to share with your team: @@ -481,6 +510,9 @@ ls -la .gemini/hooks/my-hook.sh chmod +x .gemini/hooks/my-hook.sh ``` +**Windows Note**: On Windows, ensure your execution policy allows running +scripts (e.g., `Get-ExecutionPolicy`). + **Verify script path:** Ensure the path in `settings.json` resolves correctly. ```bash diff --git a/docs/hooks/writing-hooks.md b/docs/hooks/writing-hooks.md index 33357fccb2..ca40d1976c 100644 --- a/docs/hooks/writing-hooks.md +++ b/docs/hooks/writing-hooks.md @@ -28,6 +28,8 @@ Create a directory for hooks and a simple logging script. > This example uses `jq` to parse JSON. If you don't have it installed, you can > perform similar logic using Node.js or Python. +**macOS/Linux** + ```bash mkdir -p .gemini/hooks cat > .gemini/hooks/log-tools.sh << 'EOF' @@ -52,6 +54,28 @@ EOF chmod +x .gemini/hooks/log-tools.sh ``` +**Windows (PowerShell)** + +```powershell +New-Item -ItemType Directory -Force -Path ".gemini\hooks" +@" +# Read hook input from stdin +`$inputJson = `$input | Out-String | ConvertFrom-Json + +# Extract tool name +`$toolName = `$inputJson.tool_name + +# Log to stderr (visible in terminal if hook fails, or captured in logs) +[Console]::Error.WriteLine("Logging tool: `$toolName") + +# Log to file +"[`$(Get-Date -Format 'o')] Tool executed: `$toolName" | Out-File -FilePath ".gemini\tool-log.txt" -Append -Encoding utf8 + +# Return success with empty JSON +"{}" +"@ | Out-File -FilePath ".gemini\hooks\log-tools.ps1" -Encoding utf8 +``` + ## Exit Code Strategies There are two ways to control or block an action in Gemini CLI: diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md index f16be2e730..6686421ca4 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -177,10 +177,18 @@ standalone terminal and want to manually associate it with a specific IDE instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the process ID (PID) of your IDE. +**macOS/Linux** + ```bash export GEMINI_CLI_IDE_PID=12345 ``` +**Windows (PowerShell)** + +```powershell +$env:GEMINI_CLI_IDE_PID=12345 +``` + When this variable is set, Gemini CLI will skip automatic detection and attempt to connect using the provided PID. diff --git a/docs/local-development.md b/docs/local-development.md index e194307eae..f710e3b00e 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -1,23 +1,21 @@ # Local development guide This guide provides instructions for setting up and using local development -features, such as development tracing. +features, such as tracing. -## Development tracing +## Tracing -Development traces (dev traces) are OpenTelemetry (OTel) traces that help you -debug your code by instrumenting interesting events like model calls, tool -scheduler, tool calls, etc. +Traces are OpenTelemetry (OTel) records that help you debug your code by +instrumenting key events like model calls, tool scheduler operations, and tool +calls. -Dev traces are verbose and are specifically meant for understanding agent -behavior and debugging issues. They are disabled by default. +Traces provide deep visibility into agent behavior and are invaluable for +debugging complex issues. They are captured automatically when telemetry is +enabled. -To enable dev traces, set the `GEMINI_DEV_TRACING=true` environment variable -when running Gemini CLI. +### Viewing traces -### Viewing dev traces - -You can view dev traces using either Jaeger or the Genkit Developer UI. +You can view traces using either Jaeger or the Genkit Developer UI. #### Using Genkit @@ -37,13 +35,12 @@ Genkit provides a web-based UI for viewing traces and other telemetry data. Genkit Developer UI: http://localhost:4000 ``` -2. **Run Gemini CLI with dev tracing:** +2. **Run Gemini CLI:** - In a separate terminal, run your Gemini CLI command with the - `GEMINI_DEV_TRACING` environment variable: + In a separate terminal, run your Gemini CLI command: ```bash - GEMINI_DEV_TRACING=true gemini + gemini ``` 3. **View the traces:** @@ -53,7 +50,7 @@ Genkit provides a web-based UI for viewing traces and other telemetry data. #### Using Jaeger -You can view dev traces in the Jaeger UI. To get started, follow these steps: +You can view traces in the Jaeger UI. To get started, follow these steps: 1. **Start the telemetry collector:** @@ -67,13 +64,12 @@ You can view dev traces in the Jaeger UI. To get started, follow these steps: This command also configures your workspace for local telemetry and provides a link to the Jaeger UI (usually `http://localhost:16686`). -2. **Run Gemini CLI with dev tracing:** +2. **Run Gemini CLI:** - In a separate terminal, run your Gemini CLI command with the - `GEMINI_DEV_TRACING` environment variable: + In a separate terminal, run your Gemini CLI command: ```bash - GEMINI_DEV_TRACING=true gemini + gemini ``` 3. **View the traces:** @@ -84,10 +80,10 @@ You can view dev traces in the Jaeger UI. To get started, follow these steps: For more detailed information on telemetry, see the [telemetry documentation](./cli/telemetry.md). -### Instrumenting code with dev traces +### Instrumenting code with traces -You can add dev traces to your own code for more detailed instrumentation. This -is useful for debugging and understanding the flow of execution. +You can add traces to your own code for more detailed instrumentation. This is +useful for debugging and understanding the flow of execution. Use the `runInDevTraceSpan` function to wrap any section of code in a trace span. @@ -96,29 +92,39 @@ Here is a basic example: ```typescript import { runInDevTraceSpan } from '@google/gemini-cli-core'; +import { GeminiCliOperation } from '@google/gemini-cli-core/lib/telemetry/constants.js'; -await runInDevTraceSpan({ name: 'my-custom-span' }, async ({ metadata }) => { - // The `metadata` object allows you to record the input and output of the - // operation as well as other attributes. - metadata.input = { key: 'value' }; - // Set custom attributes. - metadata.attributes['gen_ai.request.model'] = 'gemini-4.0-mega'; +await runInDevTraceSpan( + { + operation: GeminiCliOperation.ToolCall, + attributes: { + [GEN_AI_AGENT_NAME]: 'gemini-cli', + }, + }, + async ({ metadata }) => { + // The `metadata` object allows you to record the input and output of the + // operation as well as other attributes. + metadata.input = { key: 'value' }; + // Set custom attributes. + metadata.attributes['custom.attribute'] = 'custom.value'; - // Your code to be traced goes here - try { - const output = await somethingRisky(); - metadata.output = output; - return output; - } catch (e) { - metadata.error = e; - throw e; - } -}); + // Your code to be traced goes here + try { + const output = await somethingRisky(); + metadata.output = output; + return output; + } catch (e) { + metadata.error = e; + throw e; + } + }, +); ``` In this example: -- `name`: The name of the span, which will be displayed in the trace. +- `operation`: The operation type of the span, represented by the + `GeminiCliOperation` enum. - `metadata.input`: (Optional) An object containing the input data for the traced operation. - `metadata.output`: (Optional) An object containing the output data from the diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5337d973b8..a6c9ddccfd 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -322,6 +322,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `"tips"` - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` +- **`ui.errorVerbosity`** (enum): + - **Description:** Controls whether recoverable errors are hidden (low) or + fully shown (full). + - **Default:** `"low"` + - **Values:** `"low"`, `"full"` + - **`ui.customWittyPhrases`** (array): - **Description:** Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults. @@ -357,6 +363,15 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +#### `billing` + +- **`billing.overageStrategy`** (enum): + - **Description:** How to handle quota exhaustion when AI credits are + available. 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + - **Default:** `"ask"` + - **Values:** `"ask"`, `"always"`, `"never"` + #### `model` - **`model.name`** (string): @@ -1014,6 +1029,23 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`experimental.gemmaModelRouter.enabled`** (boolean): + - **Description:** Enable the Gemma Model Router. Requires a local endpoint + serving Gemma via the Gemini API using LiteRT-LM shim. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.classifier.host`** (string): + - **Description:** The host of the classifier. + - **Default:** `"http://localhost:9379"` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.classifier.model`** (string): + - **Description:** The model to use for the classifier. Only tested on + `gemma3-1b-gpu-custom`. + - **Default:** `"gemma3-1b-gpu-custom"` + - **Requires restart:** Yes + #### `skills` - **`skills.enabled`** (boolean): @@ -1300,7 +1332,8 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`GEMINI_MODEL`**: - Specifies the default Gemini model to use. - Overrides the hardcoded default - - Example: `export GEMINI_MODEL="gemini-3-flash-preview"` + - Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell: + `$env:GEMINI_MODEL="gemini-3-flash-preview"`) - **`GEMINI_CLI_IDE_PID`**: - Manually specifies the PID of the IDE process to use for integration. This is useful when running Gemini CLI in a standalone terminal while still @@ -1312,12 +1345,14 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - By default, this is the user's system home directory. The CLI will create a `.gemini` folder inside this directory. - Useful for shared compute environments or keeping CLI state isolated. - - Example: `export GEMINI_CLI_HOME="/path/to/user/config"` + - Example: `export GEMINI_CLI_HOME="/path/to/user/config"` (Windows + PowerShell: `$env:GEMINI_CLI_HOME="C:\path\to\user\config"`) - **`GOOGLE_API_KEY`**: - Your Google Cloud API key. - Required for using Vertex AI in express mode. - Ensure you have the necessary permissions. - - Example: `export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"`. + - Example: `export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"` (Windows PowerShell: + `$env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"`). - **`GOOGLE_CLOUD_PROJECT`**: - Your Google Cloud Project ID. - Required for using Code Assist or Vertex AI. @@ -1328,18 +1363,23 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. you have `GOOGLE_CLOUD_PROJECT` set in your global environment in Cloud Shell, it will be overridden by this default. To use a different project in Cloud Shell, you must define `GOOGLE_CLOUD_PROJECT` in a `.env` file. - - Example: `export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`. + - Example: `export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows + PowerShell: `$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`). - **`GOOGLE_APPLICATION_CREDENTIALS`** (string): - **Description:** The path to your Google Application Credentials JSON file. - **Example:** `export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/credentials.json"` + (Windows PowerShell: + `$env:GOOGLE_APPLICATION_CREDENTIALS="C:\path\to\your\credentials.json"`) - **`GOOGLE_GENAI_API_VERSION`**: - Specifies the API version to use for Gemini API requests. - When set, overrides the default API version used by the SDK. - - Example: `export GOOGLE_GENAI_API_VERSION="v1"` + - Example: `export GOOGLE_GENAI_API_VERSION="v1"` (Windows PowerShell: + `$env:GOOGLE_GENAI_API_VERSION="v1"`) - **`OTLP_GOOGLE_CLOUD_PROJECT`**: - Your Google Cloud Project ID for Telemetry in Google Cloud - - Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`. + - Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows + PowerShell: `$env:OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`). - **`GEMINI_TELEMETRY_ENABLED`**: - Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. @@ -1367,7 +1407,8 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`GOOGLE_CLOUD_LOCATION`**: - Your Google Cloud Project Location (e.g., us-central1). - Required for using Vertex AI in non-express mode. - - Example: `export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"`. + - Example: `export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"` (Windows + PowerShell: `$env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"`). - **`GEMINI_SANDBOX`**: - Alternative to the `sandbox` setting in `settings.json`. - Accepts `true`, `false`, `docker`, `podman`, or a custom command string. diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 1402422c6b..4fc28804f7 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -87,12 +87,12 @@ available combinations. #### Text Input -| Action | Keys | -| ---------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` | -| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Alt + Enter`
`Shift + Enter`
`Ctrl + J` | -| Open the current prompt in an external editor. | `Ctrl + X` | -| Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | +| Action | Keys | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` | +| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Alt + Enter`
`Shift + Enter`
`Ctrl + J` | +| Open the current prompt or the plan in an external editor. | `Ctrl + X` | +| Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | #### App Controls diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index a123634581..17d958acd0 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -10,9 +10,19 @@ confirmation. To create your first policy: 1. **Create the policy directory** if it doesn't exist: + + **macOS/Linux** + ```bash mkdir -p ~/.gemini/policies ``` + + **Windows (PowerShell)** + + ```powershell + New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.gemini\policies" + ``` + 2. **Create a new policy file** (e.g., `~/.gemini/policies/my-rules.toml`). You can use any filename ending in `.toml`; all such files in this directory will be loaded and combined: @@ -97,9 +107,10 @@ has a designated number that forms the base of the final priority calculation. | Tier | Base | Description | | :-------- | :--- | :------------------------------------------------------------------------- | | Default | 1 | Built-in policies that ship with the Gemini CLI. | -| Workspace | 2 | Policies defined in the current workspace's configuration directory. | -| User | 3 | Custom policies defined by the user. | -| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | +| Extension | 2 | Policies defined in extensions. | +| Workspace | 3 | Policies defined in the current workspace's configuration directory. | +| User | 4 | Custom policies defined by the user. | +| Admin | 5 | Policies managed by an administrator (e.g., in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: diff --git a/docs/resources/faq.md b/docs/resources/faq.md index eeb0396495..580d7875f3 100644 --- a/docs/resources/faq.md +++ b/docs/resources/faq.md @@ -5,6 +5,19 @@ problems encountered while using Gemini CLI. ## General issues +This section addresses common questions about Gemini CLI usage, security, and +troubleshooting general errors. + +### Why can't I use third-party software (e.g. Claude Code, OpenClaw, OpenCode) with Gemini CLI? + +Using third-party software, tools, or services to harvest or piggyback on Gemini +CLI's OAuth authentication to access our backend services is a direct violation +of our [applicable terms and policies](tos-privacy.md). Doing so bypasses our +intended authentication and security structures, and such actions may be grounds +for immediate suspension or termination of your account. If you would like to +use a third-party coding agent with Gemini, the supported and secure method is +to use a Vertex AI or Google AI Studio API key. + ### Why am I getting an `API error: 429 - Resource exhausted`? This error indicates that you have exceeded your API request limit. The Gemini @@ -75,10 +88,18 @@ You can configure your Google Cloud Project ID using an environment variable. Set the `GOOGLE_CLOUD_PROJECT` environment variable in your shell: +**macOS/Linux** + ```bash export GOOGLE_CLOUD_PROJECT="your-project-id" ``` +**Windows (PowerShell)** + +```powershell +$env:GOOGLE_CLOUD_PROJECT="your-project-id" +``` + To make this setting permanent, add this line to your shell's startup file (e.g., `~/.bashrc`, `~/.zshrc`). diff --git a/docs/resources/tos-privacy.md b/docs/resources/tos-privacy.md index e653e59d1d..88daf2639c 100644 --- a/docs/resources/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -7,6 +7,12 @@ is licensed under the When you use Gemini CLI to access or use Google’s services, the Terms of Service and Privacy Notices applicable to those services apply to such access and use. +Directly accessing the services powering Gemini CLI (e.g., the Gemini Code +Assist service) using third-party software, tools, or services (for example, +using OpenClaw with Gemini CLI OAuth) is a violation of applicable terms and +policies. Such actions may be grounds for suspension or termination of your +account. + Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. diff --git a/docs/resources/troubleshooting.md b/docs/resources/troubleshooting.md index 9e567652d9..ea6341a0d6 100644 --- a/docs/resources/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -55,10 +55,13 @@ topics on: - Set the `NODE_USE_SYSTEM_CA=1` environment variable to tell Node.js to use the operating system's native certificate store (where corporate certificates are typically already installed). - - Example: `export NODE_USE_SYSTEM_CA=1` + - Example: `export NODE_USE_SYSTEM_CA=1` (Windows PowerShell: + `$env:NODE_USE_SYSTEM_CA=1`) - Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file. - Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt` + (Windows PowerShell: + `$env:NODE_EXTRA_CA_CERTS="C:\path\to\your\corporate-ca.crt"`) ## Common error messages and solutions diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 22ce748918..202325a83d 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -1066,6 +1066,11 @@ command has no flags. gemini mcp list ``` +> **Note on Trust:** For security, `stdio` MCP servers (those using the +> `command` property) are only tested and displayed as "Connected" if the +> current folder is trusted. If the folder is untrusted, they will show as +> "Disconnected". Use `gemini trust` to trust the current folder. + **Example output:** ```sh @@ -1074,6 +1079,23 @@ gemini mcp list ✗ sse-server: https://api.example.com/sse (sse) - Disconnected ``` +## Troubleshooting and Diagnostics + +To minimize noise during startup, MCP connection errors for background servers +are "silent by default." If issues are detected during startup, a single +informational hint will be shown: _"MCP issues detected. Run /mcp list for +status."_ + +Detailed, actionable diagnostics for a specific server are automatically +re-enabled when: + +1. You run an interactive command like `/mcp list`, `/mcp auth`, etc. +2. The model attempts to execute a tool from that server. +3. You invoke an MCP prompt from that server. + +You can also use `gemini mcp list` from your shell to see connection errors for +all configured servers. + ### Removing a server (`gemini mcp remove`) To delete a server from your configuration, use the `remove` command with the diff --git a/eslint.config.js b/eslint.config.js index 3bc350d027..5cb8b7fcfa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -55,26 +55,8 @@ export default tseslint.config( }, }, { - // Import specific config - files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages - plugins: { - import: importPlugin, - }, - settings: { - 'import/resolver': { - node: true, - }, - }, - rules: { - ...importPlugin.configs.recommended.rules, - ...importPlugin.configs.typescript.rules, - 'import/no-default-export': 'warn', - 'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths - }, - }, - { - // General overrides and rules for the project (TS/TSX files) - files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + // Rules for packages/*/src (TS/TSX) + files: ['packages/*/src/**/*.{ts,tsx}'], plugins: { import: importPlugin, }, @@ -95,6 +77,11 @@ export default tseslint.config( }, }, rules: { + ...importPlugin.configs.recommended.rules, + ...importPlugin.configs.typescript.rules, + 'import/no-default-export': 'warn', + 'import/no-unresolved': 'off', + 'import/no-duplicates': 'error', // General Best Practice Rules (subset adapted for flat config) '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 'arrow-body-style': ['error', 'as-needed'], diff --git a/evals/README.md b/evals/README.md index eb3cf2be70..41ce3440b8 100644 --- a/evals/README.md +++ b/evals/README.md @@ -46,18 +46,20 @@ two arguments: #### Policies -Policies control how strictly a test is validated. Tests should generally use -the ALWAYS_PASSES policy to offer the strictest guarantees. - -USUALLY_PASSES exists to enable assertion of less consistent or aspirational -behaviors. +Policies control how strictly a test is validated. - `ALWAYS_PASSES`: Tests expected to pass 100% of the time. These are typically - trivial and test basic functionality. These run in every CI. + trivial and test basic functionality. These run in every CI and can block PRs + on failure. - `USUALLY_PASSES`: Tests expected to pass most of the time but may have some flakiness due to non-deterministic behaviors. These are run nightly and used to track the health of the product from build to build. +**All new behavioral evaluations must be created with the `USUALLY_PASSES` +policy.** A subset that prove to be highly stable over time may be promoted to +`ALWAYS_PASSES`. For more information, see +[Test promotion process](#test-promotion-process). + #### `EvalCase` Properties - `name`: The name of the evaluation case. @@ -76,7 +78,8 @@ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('my_feature', () => { - evalTest('ALWAYS_PASSES', { + // New tests MUST start as USUALLY_PASSES and be promoted via /promote-behavioral-eval + evalTest('USUALLY_PASSES', { name: 'should do something', prompt: 'do it', assert: async (rig, result) => { @@ -114,6 +117,39 @@ npm run test:all_evals This command sets the `RUN_EVALS` environment variable to `1`, which enables the `USUALLY_PASSES` tests. +## Ensuring Eval is Stable Prior to Check-in + +The +[Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml) +run is considered to be the source of truth for the quality of an eval test. +Each run of it executes a test 3 times in a row, for each supported model. The +result is then scored 0%, 33%, 66%, or 100% respectively, to indicate how many +of the individual executions passed. + +Googlers can schedule a manual run against their branch by clicking the link +above. + +Tests should score at least 66% with key models including Gemini 3.1 pro, Gemini +3.0 pro, and Gemini 3 flash prior to check in and they must pass 100% of the +time before they are promoted. + +## Test promotion process + +To maintain a stable and reliable CI, all new behavioral evaluations follow a +mandatory deflaking process. + +1. **Incubation**: You must create all new tests with the `USUALLY_PASSES` + policy. This lets them be monitored in the nightly runs without blocking PRs. +2. **Monitoring**: The test must complete at least 10 nightly runs across all + supported models. +3. **Promotion**: Promotion to `ALWAYS_PASSES` happens exclusively through the + `/promote-behavioral-eval` slash command. This command verifies the 100% + success rate requirement is met across many runs before updating the test + policy. + +This promotion process is essential for preventing the introduction of flaky +evaluations into the CI. + ## Reporting Results for evaluations are available on GitHub Actions: @@ -135,7 +171,7 @@ aggregated into a **Nightly Summary** attached to the workflow run. - **Pass Rate (%)**: Each cell represents the percentage of successful runs for a specific test in that workflow instance. -- **History**: The table shows the pass rates for the last 10 nightly runs, +- **History**: The table shows the pass rates for the last 7 nightly runs, allowing you to identify if a model's behavior is trending towards instability. - **Total Pass Rate**: An aggregate metric of all evaluations run in that batch. @@ -184,8 +220,35 @@ gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/ When investigating failures manually, you can also enable verbose agent logs by setting the `GEMINI_DEBUG_LOG_FILE` environment variable. +### Best practices + It's highly recommended to manually review and/or ask the agent to iterate on any prompt changes, even if they pass all evals. The prompt should prefer positive traits ('do X') and resort to negative traits ('do not do X') only when unable to accomplish the goal with positive traits. Gemini is quite good at instrospecting on its prompt when asked the right questions. + +## Promoting evaluations + +Evaluations must be promoted from `USUALLY_PASSES` to `ALWAYS_PASSES` +exclusively using the `/promote-behavioral-eval` slash command. Manual promotion +is not allowed to ensure that the 100% success rate requirement is empirically +met. + +### `/promote-behavioral-eval` + +This command automates the promotion of stable tests by: + +1. **Investigating**: Analyzing the results of the last 7 nightly runs on the + `main` branch using the `gh` CLI. +2. **Criteria Check**: Identifying tests that have passed 100% of the time for + ALL enabled models across the entire 7-run history. +3. **Promotion**: Updating the test file's policy from `USUALLY_PASSES` to + `ALWAYS_PASSES`. +4. **Verification**: Running the promoted test locally to ensure correctness. + +To run it: + +```bash +gemini /promote-behavioral-eval +``` diff --git a/evals/answer-vs-act.eval.ts b/evals/answer-vs-act.eval.ts index 7ee273fc31..4e30b828d0 100644 --- a/evals/answer-vs-act.eval.ts +++ b/evals/answer-vs-act.eval.ts @@ -88,7 +88,7 @@ describe('Answer vs. ask eval', () => { * Ensures that when the user asks a general question, the agent does NOT * automatically modify the file. */ - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should not edit files when asked a general question', prompt: 'How does app.ts work?', files: FILES, diff --git a/evals/generalist_delegation.eval.ts b/evals/generalist_delegation.eval.ts new file mode 100644 index 0000000000..7e6358ae1f --- /dev/null +++ b/evals/generalist_delegation.eval.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { appEvalTest } from './app-test-helper.js'; + +describe('generalist_delegation', () => { + // --- Positive Evals (Should Delegate) --- + + appEvalTest('USUALLY_PASSES', { + name: 'should delegate batch error fixing to generalist agent', + configOverrides: { + agents: { + overrides: { + generalist: { enabled: true }, + }, + }, + experimental: { + enableAgents: true, + }, + excludeTools: ['run_shell_command'], + }, + files: { + 'file1.ts': 'console.log("no semi")', + 'file2.ts': 'console.log("no semi")', + 'file3.ts': 'console.log("no semi")', + 'file4.ts': 'console.log("no semi")', + 'file5.ts': 'console.log("no semi")', + 'file6.ts': 'console.log("no semi")', + 'file7.ts': 'console.log("no semi")', + 'file8.ts': 'console.log("no semi")', + 'file9.ts': 'console.log("no semi")', + 'file10.ts': 'console.log("no semi")', + }, + prompt: + 'I have 10 files (file1.ts to file10.ts) that are missing semicolons. Can you fix them?', + setup: async (rig) => { + rig.setBreakpoint(['generalist']); + }, + assert: async (rig) => { + const confirmation = await rig.waitForPendingConfirmation( + 'generalist', + 60000, + ); + expect( + confirmation, + 'Expected a tool call for generalist agent', + ).toBeTruthy(); + await rig.resolveTool(confirmation); + await rig.waitForIdle(60000); + }, + }); + + appEvalTest('USUALLY_PASSES', { + name: 'should autonomously delegate complex batch task to generalist agent', + configOverrides: { + agents: { + overrides: { + generalist: { enabled: true }, + }, + }, + experimental: { + enableAgents: true, + }, + excludeTools: ['run_shell_command'], + }, + files: { + 'src/a.ts': 'export const a = 1;', + 'src/b.ts': 'export const b = 2;', + 'src/c.ts': 'export const c = 3;', + 'src/d.ts': 'export const d = 4;', + 'src/e.ts': 'export const e = 5;', + }, + prompt: + 'Please update all files in the src directory. For each file, add a comment at the top that says "Processed by Gemini".', + setup: async (rig) => { + rig.setBreakpoint(['generalist']); + }, + assert: async (rig) => { + const confirmation = await rig.waitForPendingConfirmation( + 'generalist', + 60000, + ); + expect( + confirmation, + 'Expected autonomously delegate to generalist for batch task', + ).toBeTruthy(); + await rig.resolveTool(confirmation); + await rig.waitForIdle(60000); + }, + }); + + // --- Negative Evals (Should NOT Delegate - Assertive Handling) --- + + appEvalTest('USUALLY_PASSES', { + name: 'should NOT delegate simple read and fix to generalist agent', + configOverrides: { + agents: { + overrides: { + generalist: { enabled: true }, + }, + }, + experimental: { + enableAgents: true, + }, + excludeTools: ['run_shell_command'], + }, + files: { + 'README.md': 'This is a proyect.', + }, + prompt: + 'There is a typo in README.md ("proyect"). Please fix it to "project".', + setup: async (rig) => { + // Break on everything to see what it calls + rig.setBreakpoint(['*']); + }, + assert: async (rig) => { + await rig.drainBreakpointsUntilIdle((confirmation) => { + expect( + confirmation.toolName, + `Agent should NOT have delegated to generalist.`, + ).not.toBe('generalist'); + }); + + const output = rig.getStaticOutput(); + expect(output).toMatch(/project/i); + }, + }); + + appEvalTest('USUALLY_PASSES', { + name: 'should NOT delegate simple direct question to generalist agent', + configOverrides: { + agents: { + overrides: { + generalist: { enabled: true }, + }, + }, + experimental: { + enableAgents: true, + }, + excludeTools: ['run_shell_command'], + }, + files: { + 'src/VERSION': '1.2.3', + }, + prompt: 'Can you tell me the version number in the src folder?', + setup: async (rig) => { + rig.setBreakpoint(['*']); + }, + assert: async (rig) => { + await rig.drainBreakpointsUntilIdle((confirmation) => { + expect( + confirmation.toolName, + `Agent should NOT have delegated to generalist.`, + ).not.toBe('generalist'); + }); + + const output = rig.getStaticOutput(); + expect(output).toMatch(/1\.2\.3/); + }, + }); +}); diff --git a/evals/gitRepo.eval.ts b/evals/gitRepo.eval.ts index ea51d196ac..6415b9c20d 100644 --- a/evals/gitRepo.eval.ts +++ b/evals/gitRepo.eval.ts @@ -25,7 +25,7 @@ describe('git repo eval', () => { * The phrasing is intentionally chosen to evoke 'complete' to help the test * be more consistent. */ - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should not git add commit changes unprompted', prompt: 'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests', diff --git a/evals/grep_search_functionality.eval.ts b/evals/grep_search_functionality.eval.ts index 77df3b950f..f1224b8221 100644 --- a/evals/grep_search_functionality.eval.ts +++ b/evals/grep_search_functionality.eval.ts @@ -93,7 +93,7 @@ describe('grep_search_functionality', () => { }); evalTest('USUALLY_PASSES', { - name: 'should search only within the specified include glob', + name: 'should search only within the specified include_pattern glob', files: { 'file.js': 'my_function();', 'file.ts': 'my_function();', @@ -105,19 +105,19 @@ describe('grep_search_functionality', () => { undefined, (args) => { const params = JSON.parse(args); - return params.include === '*.js'; + return params.include_pattern === '*.js'; }, ); expect( wasToolCalled, - 'Expected grep_search to be called with include: "*.js"', + 'Expected grep_search to be called with include_pattern: "*.js"', ).toBe(true); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/file.js/], forbiddenContent: [/file.ts/], - testName: `${TEST_PREFIX}include glob search`, + testName: `${TEST_PREFIX}include_pattern glob search`, }); }, }); diff --git a/evals/hierarchical_memory.eval.ts b/evals/hierarchical_memory.eval.ts index 71f9cc3e43..ff7483416b 100644 --- a/evals/hierarchical_memory.eval.ts +++ b/evals/hierarchical_memory.eval.ts @@ -86,7 +86,7 @@ Provide the answer as an XML block like this: }); const extensionVsGlobalTest = 'Extension memory wins over Global memory'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: extensionVsGlobalTest, params: { settings: { diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts index ff70a2b4ad..29566eab86 100644 --- a/evals/plan_mode.eval.ts +++ b/evals/plan_mode.eval.ts @@ -18,7 +18,7 @@ describe('plan_mode', () => { experimental: { plan: true }, }; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should refuse file modification when in plan mode', approvalMode: ApprovalMode.PLAN, params: { @@ -57,7 +57,7 @@ describe('plan_mode', () => { }, }); - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should refuse saving new documentation to the repo when in plan mode', approvalMode: ApprovalMode.PLAN, params: { diff --git a/evals/save_memory.eval.ts b/evals/save_memory.eval.ts index 11f0c932d9..e4fe9bc687 100644 --- a/evals/save_memory.eval.ts +++ b/evals/save_memory.eval.ts @@ -125,7 +125,7 @@ describe('save_memory', () => { }); const rememberingCommandAlias = 'Agent remembers custom command aliases'; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingCommandAlias, params: { settings: { tools: { core: ['save_memory'] } }, @@ -178,7 +178,7 @@ describe('save_memory', () => { const rememberingCodingStyle = "Agent remembers user's coding style preference"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingCodingStyle, params: { settings: { tools: { core: ['save_memory'] } }, @@ -260,7 +260,7 @@ describe('save_memory', () => { }); const rememberingBirthday = "Agent remembers user's birthday"; - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: rememberingBirthday, params: { settings: { tools: { core: ['save_memory'] } }, diff --git a/evals/shell-efficiency.eval.ts b/evals/shell-efficiency.eval.ts index fbb8cc133e..dc555d5298 100644 --- a/evals/shell-efficiency.eval.ts +++ b/evals/shell-efficiency.eval.ts @@ -72,7 +72,7 @@ describe('Shell Efficiency', () => { }, }); - evalTest('USUALLY_PASSES', { + evalTest('ALWAYS_PASSES', { name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled', params: { settings: { diff --git a/evals/validation_fidelity.eval.ts b/evals/validation_fidelity.eval.ts index d8f571773d..8cfb4f6626 100644 --- a/evals/validation_fidelity.eval.ts +++ b/evals/validation_fidelity.eval.ts @@ -8,7 +8,7 @@ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('validation_fidelity', () => { - evalTest('ALWAYS_PASSES', { + evalTest('USUALLY_PASSES', { name: 'should perform exhaustive validation autonomously when guided by system instructions', files: { 'src/types.ts': ` diff --git a/integration-tests/acp-telemetry.test.ts b/integration-tests/acp-telemetry.test.ts index 970239de9e..393156df3e 100644 --- a/integration-tests/acp-telemetry.test.ts +++ b/integration-tests/acp-telemetry.test.ts @@ -72,7 +72,6 @@ describe('ACP telemetry', () => { GEMINI_TELEMETRY_ENABLED: 'true', GEMINI_TELEMETRY_TARGET: 'local', GEMINI_TELEMETRY_OUTFILE: telemetryPath, - // GEMINI_DEV_TRACING not set: fake responses aren't instrumented for spans }, }, ); diff --git a/integration-tests/file-system.test.ts b/integration-tests/file-system.test.ts index bdcffedaf8..64481068c2 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/file-system.test.ts @@ -55,8 +55,8 @@ describe('file-system', () => { }); }); - it('should be able to write a file', async () => { - await rig.setup('should be able to write a file', { + it('should be able to write a hello world message to a file', async () => { + await rig.setup('should be able to write a hello world message to a file', { settings: { tools: { core: ['write_file', 'replace', 'read_file'] } }, }); rig.createFile('test.txt', ''); diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index 784bb890a0..f71006a36c 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -62,7 +62,7 @@ describe('Plan Mode', () => { }); }); - it('should allow write_file only in the plans directory in plan mode', async () => { + it.skip('should allow write_file only in the plans directory in plan mode', async () => { await rig.setup( 'should allow write_file only in the plans directory in plan mode', { diff --git a/integration-tests/ripgrep-real.test.ts b/integration-tests/ripgrep-real.test.ts index 3ac8a0f16e..60f99c8a84 100644 --- a/integration-tests/ripgrep-real.test.ts +++ b/integration-tests/ripgrep-real.test.ts @@ -102,7 +102,10 @@ describe('ripgrep-real-direct', () => { 'console.log("hello");\n', ); - const invocation = tool.build({ pattern: 'hello', include: '*.js' }); + const invocation = tool.build({ + pattern: 'hello', + include_pattern: '*.js', + }); const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toContain('Found 1 match'); diff --git a/integration-tests/write_file.test.ts b/integration-tests/write_file.test.ts index 8069b1ca87..ece2a11aa4 100644 --- a/integration-tests/write_file.test.ts +++ b/integration-tests/write_file.test.ts @@ -22,8 +22,8 @@ describe('write_file', () => { afterEach(async () => await rig.cleanup()); - it('should be able to write a file', async () => { - await rig.setup('should be able to write a file', { + it('should be able to write a joke to a file', async () => { + await rig.setup('should be able to write a joke to a file', { settings: { tools: { core: ['write_file', 'read_file'] } }, }); const prompt = `show me an example of using the write tool. put a dad joke in dad.txt`; diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts index e2287a2562..7fc35657fb 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -12,23 +12,22 @@ import type { RequestContext, ExecutionEventBus, } from '@a2a-js/sdk/server'; -import type { ToolCallRequestInfo, Config } from '@google/gemini-cli-core'; import { GeminiEventType, SimpleExtensionLoader, + type ToolCallRequestInfo, + type Config, } from '@google/gemini-cli-core'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; -import type { - StateChange, - AgentSettings, - PersistedStateMetadata, -} from '../types.js'; import { CoderAgentEvent, getPersistedState, setPersistedState, + type StateChange, + type AgentSettings, + type PersistedStateMetadata, getContextIdFromMetadata, getAgentSettingsFromMetadata, } from '../types.js'; diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index 81987a780b..e29f669333 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -14,17 +14,15 @@ import { type Mock, } from 'vitest'; import { Task } from './task.js'; -import type { - ToolCall, - Config, - ToolCallRequestInfo, - GitService, - CompletedToolCall, -} from '@google/gemini-cli-core'; import { GeminiEventType, ApprovalMode, ToolConfirmationOutcome, + type Config, + type ToolCallRequestInfo, + type GitService, + type CompletedToolCall, + type ToolCall, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index c91ef72781..1defbdd36c 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -31,7 +31,10 @@ import { EDIT_TOOL_NAMES, processRestorableToolCalls, } from '@google/gemini-cli-core'; -import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server'; +import { + type ExecutionEventBus, + type RequestContext, +} from '@a2a-js/sdk/server'; import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent, @@ -44,16 +47,16 @@ import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { CoderAgentEvent } from '../types.js'; -import type { - CoderAgentMessage, - StateChange, - ToolCallUpdate, - TextContent, - TaskMetadata, - Thought, - ThoughtSummary, - Citation, +import { + CoderAgentEvent, + type CoderAgentMessage, + type StateChange, + type ToolCallUpdate, + type TextContent, + type TaskMetadata, + type Thought, + type ThoughtSummary, + type Citation, } from '../types.js'; import type { PartUnion, Part as genAiPart } from '@google/genai'; diff --git a/packages/a2a-server/src/commands/init.test.ts b/packages/a2a-server/src/commands/init.test.ts index df2a213cba..b7020e0729 100644 --- a/packages/a2a-server/src/commands/init.test.ts +++ b/packages/a2a-server/src/commands/init.test.ts @@ -6,7 +6,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { InitCommand } from './init.js'; -import { performInit } from '@google/gemini-cli-core'; +import { + performInit, + type CommandActionReturn, + type Config, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { CoderAgentExecutor } from '../agent/executor.js'; @@ -14,7 +18,6 @@ import { CoderAgentEvent } from '../types.js'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import { createMockConfig } from '../utils/testing_utils.js'; import type { CommandContext } from './types.js'; -import type { CommandActionReturn, Config } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { diff --git a/packages/a2a-server/src/commands/memory.test.ts b/packages/a2a-server/src/commands/memory.test.ts index 40c5d1b90b..975b517c78 100644 --- a/packages/a2a-server/src/commands/memory.test.ts +++ b/packages/a2a-server/src/commands/memory.test.ts @@ -9,6 +9,9 @@ import { listMemoryFiles, refreshMemory, showMemory, + type AnyDeclarativeTool, + type Config, + type ToolRegistry, } from '@google/gemini-cli-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -19,11 +22,6 @@ import { ShowMemoryCommand, } from './memory.js'; import type { CommandContext } from './types.js'; -import type { - AnyDeclarativeTool, - Config, - ToolRegistry, -} from '@google/gemini-cli-core'; // Mock the core functions vi.mock('@google/gemini-cli-core', async (importOriginal) => { diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 6a27bca4d5..0873f43d98 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -8,11 +8,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as dotenv from 'dotenv'; -import type { - TelemetryTarget, - ConfigParameters, - ExtensionLoader, -} from '@google/gemini-cli-core'; import { AuthType, Config, @@ -28,6 +23,9 @@ import { fetchAdminControlsOnce, getCodeAssistServer, ExperimentFlags, + type TelemetryTarget, + type ConfigParameters, + type ExtensionLoader, } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index c863fb1472..7262be42a8 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Config, - ToolCallConfirmationDetails, +import { + GeminiEventType, + ApprovalMode, + type Config, + type ToolCallConfirmationDetails, } from '@google/gemini-cli-core'; -import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core'; import type { TaskStatusUpdateEvent, SendStreamingMessageSuccessResponse, diff --git a/packages/a2a-server/src/persistence/gcs.test.ts b/packages/a2a-server/src/persistence/gcs.test.ts index 43563448e5..353a8312d5 100644 --- a/packages/a2a-server/src/persistence/gcs.test.ts +++ b/packages/a2a-server/src/persistence/gcs.test.ts @@ -11,8 +11,16 @@ import { gzipSync, gunzipSync } from 'node:zlib'; import { v4 as uuidv4 } from 'uuid'; import type { Task as SDKTask } from '@a2a-js/sdk'; import type { TaskStore } from '@a2a-js/sdk/server'; -import type { Mocked, MockedClass, Mock } from 'vitest'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + describe, + it, + expect, + beforeEach, + vi, + type Mocked, + type MockedClass, + type Mock, +} from 'vitest'; import { GCSTaskStore, NoOpTaskStore } from './gcs.js'; import { logger } from '../utils/logger.js'; diff --git a/packages/a2a-server/src/utils/executor_utils.ts b/packages/a2a-server/src/utils/executor_utils.ts index b595a6905b..cf635f8822 100644 --- a/packages/a2a-server/src/utils/executor_utils.ts +++ b/packages/a2a-server/src/utils/executor_utils.ts @@ -8,8 +8,7 @@ import type { Message } from '@a2a-js/sdk'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import { v4 as uuidv4 } from 'uuid'; -import { CoderAgentEvent } from '../types.js'; -import type { StateChange } from '../types.js'; +import { CoderAgentEvent, type StateChange } from '../types.js'; export async function pushTaskStateFailed( error: unknown, diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 9cb0657c7a..977daedf16 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -18,9 +18,10 @@ import { HookSystem, PolicyDecision, tmpdir, + type Config, + type Storage, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; -import type { Config, Storage } from '@google/gemini-cli-core'; import { expect, vi } from 'vitest'; export function createMockConfig( diff --git a/packages/cli/GEMINI.md b/packages/cli/GEMINI.md index 8ab50f6b57..5518696d60 100644 --- a/packages/cli/GEMINI.md +++ b/packages/cli/GEMINI.md @@ -15,4 +15,11 @@ - **Utilities**: Use `renderWithProviders` and `waitFor` from `packages/cli/src/test-utils/`. - **Snapshots**: Use `toMatchSnapshot()` to verify Ink output. +- **SVG Snapshots**: Use `await expect(renderResult).toMatchSvgSnapshot()` for + UI components whenever colors or detailed visual layout matter. SVG snapshots + capture styling accurately. Make sure to await the `waitUntilReady()` of the + render result before asserting. After updating SVG snapshots, always examine + the resulting `.svg` files (e.g. by reading their content or visually + inspecting them) to ensure the render and colors actually look as expected and + don't just contain an error message. - **Mocks**: Use mocks as sparingly as possible. diff --git a/packages/cli/src/commands/extensions/examples/policies/README.md b/packages/cli/src/commands/extensions/examples/policies/README.md new file mode 100644 index 0000000000..d1c06de6e3 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/README.md @@ -0,0 +1,41 @@ +# Policy engine example extension + +This extension demonstrates how to contribute security rules and safety checkers +to the Gemini CLI Policy Engine. + +## Description + +The extension uses a `policies/` directory containing `.toml` files to define: + +- A rule that requires user confirmation for `rm -rf` commands. +- A rule that denies searching for sensitive files (like `.env`) using `grep`. +- A safety checker that validates file paths for all write operations. + +## Structure + +- `gemini-extension.json`: The manifest file. +- `policies/`: Contains the `.toml` policy files. + +## How to use + +1. Link this extension to your local Gemini CLI installation: + + ```bash + gemini extensions link packages/cli/src/commands/extensions/examples/policies + ``` + +2. Restart your Gemini CLI session. + +3. **Observe the policies:** + - Try asking the model to delete a directory: The policy engine will prompt + you for confirmation due to the `rm -rf` rule. + - Try asking the model to search for secrets: The `grep` rule will deny the + request and display the custom deny message. + - Any file write operation will now be processed through the `allowed-path` + safety checker. + +## Security note + +For security, Gemini CLI ignores any `allow` decisions or `yolo` mode +configurations contributed by extensions. This ensures that extensions can +strengthen security but cannot bypass user confirmation. diff --git a/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json b/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json new file mode 100644 index 0000000000..2a2b992532 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "policy-example", + "version": "1.0.0", + "description": "An example extension demonstrating Policy Engine support." +} diff --git a/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml b/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml new file mode 100644 index 0000000000..d89d5e5737 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml @@ -0,0 +1,28 @@ +# Example Policy Rules for Gemini CLI Extension +# +# Extensions run in Tier 2 (Extension Tier). +# Security Note: 'allow' decisions and 'yolo' mode configurations are ignored. + +# Rule: Always ask the user before running a specific dangerous shell command. +[[rule]] +toolName = "run_shell_command" +commandPrefix = "rm -rf" +decision = "ask_user" +priority = 100 + +# Rule: Deny access to sensitive files using the grep tool. +[[rule]] +toolName = "grep_search" +argsPattern = "(\.env|id_rsa|passwd)" +decision = "deny" +priority = 200 +deny_message = "Access to sensitive credentials or system files is restricted by the policy-example extension." + +# Safety Checker: Apply path validation to all write operations. +[[safety_checker]] +toolName = ["write_file", "replace"] +priority = 300 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +required_context = ["environment"] diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 60912c51f5..aaaf667815 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -4,7 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import { listMcpServers } from './list.js'; import { loadSettings, mergeSettings } from '../../config/settings.js'; import { createTransport, debugLogger } from '@google/gemini-cli-core'; @@ -106,6 +114,10 @@ describe('mcp list command', () => { mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir'); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should display message when no servers configured', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ @@ -133,6 +145,7 @@ describe('mcp list command', () => { }, }, }, + isTrusted: true, }); mockClient.connect.mockResolvedValue(undefined); @@ -199,6 +212,7 @@ describe('mcp list command', () => { 'config-server': { command: '/config/server' }, }, }, + isTrusted: true, }); mockExtensionManager.loadExtensions.mockReturnValue([ @@ -266,4 +280,28 @@ describe('mcp list command', () => { expect.anything(), ); }); + + it('should show stdio servers as disconnected in untrusted folders', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { + ...defaultMergedSettings, + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + }, + isTrusted: false, + }); + + // createTransport will throw in core if not trusted + mockedCreateTransport.mockRejectedValue(new Error('Folder not trusted')); + + await listMcpServers(); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining( + 'test-server: /test/server (stdio) - Disconnected', + ), + ); + }); }); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index d51093fbfa..421c822a55 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -20,11 +20,7 @@ import { ExtensionManager } from '../../config/extension-manager.js'; import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; import { promptForSetting } from '../../config/extensions/extensionSettings.js'; import { exitCli } from '../utils.js'; - -const COLOR_GREEN = '\u001b[32m'; -const COLOR_YELLOW = '\u001b[33m'; -const COLOR_RED = '\u001b[31m'; -const RESET_COLOR = '\u001b[0m'; +import chalk from 'chalk'; export async function getMcpServersFromConfig( settings?: MergedSettings, @@ -66,27 +62,56 @@ async function testMCPConnection( serverName: string, config: MCPServerConfig, ): Promise { + const settings = loadSettings(); + + // SECURITY: Only test connection if workspace is trusted or if it's a remote server. + // stdio servers execute local commands and must never run in untrusted workspaces. + const isStdio = !!config.command; + if (isStdio && !settings.isTrusted) { + return MCPServerStatus.DISCONNECTED; + } + const client = new Client({ name: 'mcp-test-client', version: '0.0.1', }); - const settings = loadSettings(); - const sanitizationConfig = { - enableEnvironmentVariableRedaction: true, - allowedEnvironmentVariables: [], - blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, + const mcpContext = { + sanitizationConfig: { + enableEnvironmentVariableRedaction: true, + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, + }, + emitMcpDiagnostic: ( + severity: 'info' | 'warning' | 'error', + message: string, + error?: unknown, + serverName?: string, + ) => { + // In non-interactive list, we log everything through debugLogger for consistency + if (severity === 'error') { + debugLogger.error( + chalk.red(`Error${serverName ? ` (${serverName})` : ''}: ${message}`), + error, + ); + } else if (severity === 'warning') { + debugLogger.warn( + chalk.yellow( + `Warning${serverName ? ` (${serverName})` : ''}: ${message}`, + ), + error, + ); + } else { + debugLogger.log(message, error); + } + }, + isTrustedFolder: () => settings.isTrusted, }; let transport; try { // Use the same transport creation logic as core - transport = await createTransport( - serverName, - config, - false, - sanitizationConfig, - ); + transport = await createTransport(serverName, config, false, mcpContext); } catch (_error) { await client.close(); return MCPServerStatus.DISCONNECTED; @@ -125,7 +150,7 @@ export async function listMcpServers(settings?: MergedSettings): Promise { blockedServerNames, undefined, ); - debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n'); + debugLogger.log(chalk.yellow(message + '\n')); } if (serverNames.length === 0) { @@ -146,16 +171,16 @@ export async function listMcpServers(settings?: MergedSettings): Promise { let statusText = ''; switch (status) { case MCPServerStatus.CONNECTED: - statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR; + statusIndicator = chalk.green('✓'); statusText = 'Connected'; break; case MCPServerStatus.CONNECTING: - statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR; + statusIndicator = chalk.yellow('…'); statusText = 'Connecting'; break; case MCPServerStatus.DISCONNECTED: default: - statusIndicator = COLOR_RED + '✗' + RESET_COLOR; + statusIndicator = chalk.red('✗'); statusText = 'Disconnected'; break; } diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 75812e4442..919ad86c51 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2765,6 +2765,66 @@ describe('loadCliConfig approval mode', () => { }); }); +describe('loadCliConfig gemmaModelRouter', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should have gemmaModelRouter disabled by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(false); + }); + + it('should load gemmaModelRouter settings from merged settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + gemmaModelRouter: { + enabled: true, + classifier: { + host: 'http://custom:1234', + model: 'custom-gemma', + }, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(true); + const gemmaSettings = config.getGemmaModelRouterSettings(); + expect(gemmaSettings.classifier?.host).toBe('http://custom:1234'); + expect(gemmaSettings.classifier?.model).toBe('custom-gemma'); + }); + + it('should handle partial gemmaModelRouter settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + gemmaModelRouter: { + enabled: true, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(true); + const gemmaSettings = config.getGemmaModelRouterSettings(); + expect(gemmaSettings.classifier?.host).toBe('http://localhost:9379'); + expect(gemmaSettings.classifier?.model).toBe('gemma3-1b-gpu-custom'); + }); +}); + describe('loadCliConfig fileFiltering', () => { const originalArgv = process.argv; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6a4bd09470..bbc8b1681e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -843,6 +843,7 @@ export async function loadCliConfig( interactive, trustedFolder, useBackgroundColor: settings.ui?.useBackgroundColor, + useAlternateBuffer: settings.ui?.useAlternateBuffer, useRipgrep: settings.tools?.useRipgrep, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, @@ -856,6 +857,7 @@ export async function loadCliConfig( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, + gemmaModelRouter: settings.experimental?.gemmaModelRouter, fakeResponses: argv.fakeResponses, recordResponses: argv.recordResponses, retryFetchErrors: settings.general?.retryFetchErrors, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 93ad3f3536..56152cd6e1 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -52,6 +52,10 @@ import { applyAdminAllowlist, getAdminBlockedMcpServersMessage, CoreToolCallStatus, + loadExtensionPolicies, + isSubpath, + type PolicyRule, + type SafetyCheckerRule, HookType, } from '@google/gemini-cli-core'; import { maybeRequestConsentOrFail } from './extensions/consent.js'; @@ -764,9 +768,18 @@ Would you like to attempt to install via "git clone" instead?`, } const contextFiles = getContextFileNames(config) - .map((contextFileName) => - path.join(effectiveExtensionPath, contextFileName), - ) + .map((contextFileName) => { + const contextFilePath = path.join( + effectiveExtensionPath, + contextFileName, + ); + if (!isSubpath(effectiveExtensionPath, contextFilePath)) { + throw new Error( + `Invalid context file path: "${contextFileName}". Context files must be within the extension directory.`, + ); + } + return contextFilePath; + }) .filter((contextFilePath) => fs.existsSync(contextFilePath)); const hydrationContext: VariableContext = { @@ -820,6 +833,24 @@ Would you like to attempt to install via "git clone" instead?`, recursivelyHydrateStrings(skill, hydrationContext), ); + let rules: PolicyRule[] | undefined; + let checkers: SafetyCheckerRule[] | undefined; + + const policyDir = path.join(effectiveExtensionPath, 'policies'); + if (fs.existsSync(policyDir)) { + const result = await loadExtensionPolicies(config.name, policyDir); + rules = result.rules; + checkers = result.checkers; + + if (result.errors.length > 0) { + for (const error of result.errors) { + debugLogger.warn( + `[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`, + ); + } + } + } + const agentLoadResult = await loadAgentsFromDirectory( path.join(effectiveExtensionPath, 'agents'), ); @@ -853,6 +884,8 @@ Would you like to attempt to install via "git clone" instead?`, skills, agents: agentLoadResult.agents, themes: config.themes, + rules, + checkers, }; } catch (e) { debugLogger.error( diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index affcd0cef0..f8e66bf8e2 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -239,6 +239,27 @@ 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(() => {}); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'traversal-extension', + version: '1.0.0', + contextFileName: '../secret.txt', + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(0); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'traversal-extension: Invalid context file path: "../secret.txt"', + ), + ); + consoleSpy.mockRestore(); + }); + it('should load context file path when GEMINI.md is present', async () => { createExtension({ extensionsDir: userExtensionsDir, @@ -363,6 +384,111 @@ describe('extension tests', () => { ]); }); + it('should load extension policies from the policies directory', async () => { + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'policy-extension', + version: '1.0.0', + }); + + const policiesDir = path.join(extDir, 'policies'); + fs.mkdirSync(policiesDir); + + const policiesContent = ` +[[rule]] +toolName = "deny_tool" +decision = "deny" +priority = 500 + +[[rule]] +toolName = "ask_tool" +decision = "ask_user" +priority = 100 +`; + fs.writeFileSync( + path.join(policiesDir, 'policies.toml'), + policiesContent, + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + + expect(extension.rules).toBeDefined(); + expect(extension.rules).toHaveLength(2); + expect( + extension.rules!.find((r) => r.toolName === 'deny_tool')?.decision, + ).toBe('deny'); + expect( + extension.rules!.find((r) => r.toolName === 'ask_tool')?.decision, + ).toBe('ask_user'); + // Verify source is prefixed + expect(extension.rules![0].source).toContain( + 'Extension (policy-extension):', + ); + }); + + it('should ignore ALLOW rules and YOLO mode from extension policies for security', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'security-test-extension', + version: '1.0.0', + }); + + const policiesDir = path.join(extDir, 'policies'); + fs.mkdirSync(policiesDir); + + const policiesContent = ` +[[rule]] +toolName = "allow_tool" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "yolo_tool" +decision = "ask_user" +priority = 100 +modes = ["yolo"] + +[[safety_checker]] +toolName = "yolo_check" +priority = 100 +modes = ["yolo"] +[safety_checker.checker] +type = "external" +name = "yolo-checker" +`; + fs.writeFileSync( + path.join(policiesDir, 'policies.toml'), + policiesContent, + ); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + + // ALLOW rules and YOLO rules/checkers should be filtered out + expect(extension.rules).toBeDefined(); + expect(extension.rules).toHaveLength(0); + expect(extension.checkers).toBeDefined(); + expect(extension.checkers).toHaveLength(0); + + // Should have logged warnings + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('attempted to contribute an ALLOW rule'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('attempted to contribute a rule for YOLO mode'), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'attempted to contribute a safety checker for YOLO mode', + ), + ); + consoleSpy.mockRestore(); + }); + it('should hydrate ${extensionPath} correctly for linked extensions', async () => { const sourceExtDir = getRealPath( createExtension({ @@ -540,7 +666,7 @@ describe('extension tests', () => { // Bad extension const badExtDir = path.join(userExtensionsDir, 'bad-ext'); - fs.mkdirSync(badExtDir); + fs.mkdirSync(badExtDir, { recursive: true }); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed @@ -548,7 +674,7 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( + expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, ), @@ -571,7 +697,7 @@ describe('extension tests', () => { // Bad extension const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name'); - fs.mkdirSync(badExtDir); + fs.mkdirSync(badExtDir, { recursive: true }); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); @@ -579,7 +705,7 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( + expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, ), diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 4813abd368..3122acef1d 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -489,7 +489,7 @@ export const commandDescriptions: Readonly> = { [Command.SUBMIT]: 'Submit the current prompt.', [Command.NEWLINE]: 'Insert a newline without submitting.', [Command.OPEN_EXTERNAL_EDITOR]: - 'Open the current prompt in an external editor.', + 'Open the current prompt or the plan in an external editor.', [Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.', // App Controls diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 1d7573337e..02515815d0 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -177,13 +177,13 @@ describe('Policy Engine Integration Tests', () => { ); const engine = new PolicyEngine(config); - // MCP server allowed (priority 3.1) provides general allow for server - // MCP server allowed (priority 3.1) provides general allow for server + // MCP server allowed (priority 4.1) provides general allow for server + // MCP server allowed (priority 4.1) provides general allow for server expect( (await engine.check({ name: 'my-server__safe-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); - // But specific tool exclude (priority 3.4) wins over server allow + // But specific tool exclude (priority 4.4) wins over server allow expect( (await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) .decision, @@ -476,25 +476,25 @@ describe('Policy Engine Integration Tests', () => { // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); - expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude + expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude const blockedServerRule = rules.find( (r) => r.toolName === 'blocked-server__*', ); - expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude + expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); - expect(specificToolRule?.priority).toBe(3.3); // Command line allow + expect(specificToolRule?.priority).toBe(4.3); // Command line allow const trustedServerRule = rules.find( (r) => r.toolName === 'trusted-server__*', ); - expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server + expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); - expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server + expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) @@ -641,16 +641,16 @@ describe('Policy Engine Integration Tests', () => { // Verify each rule has the expected priority const tool3Rule = rules.find((r) => r.toolName === 'tool3'); - expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier) + expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier) const server2Rule = rules.find((r) => r.toolName === 'server2__*'); - expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier) + expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); - expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier) + expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier) const server1Rule = rules.find((r) => r.toolName === 'server1__*'); - expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier) + expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index 1a773d56a7..9baccd3359 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -8,7 +8,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { resolveWorkspacePolicyState } from './policy.js'; +import { + resolveWorkspacePolicyState, + autoAcceptWorkspacePolicies, + setAutoAcceptWorkspacePolicies, + disableWorkspacePolicies, + setDisableWorkspacePolicies, +} from './policy.js'; import { writeToStderr } from '@google/gemini-cli-core'; // Mock debugLogger to avoid noise in test output @@ -41,6 +47,9 @@ describe('resolveWorkspacePolicyState', () => { fs.mkdirSync(workspaceDir); policiesDir = path.join(workspaceDir, '.gemini', 'policies'); + // Enable policies for these tests to verify loading logic + setDisableWorkspacePolicies(false); + vi.clearAllMocks(); }); @@ -63,29 +72,30 @@ describe('resolveWorkspacePolicyState', () => { }); }); + it('should have disableWorkspacePolicies set to true by default', () => { + // We explicitly set it to false in beforeEach for other tests, + // so here we test that setting it to true works. + setDisableWorkspacePolicies(true); + expect(disableWorkspacePolicies).toBe(true); + }); + it('should return policy directory if integrity matches', async () => { // Set up policies directory with a file fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); - // First call to establish integrity (interactive accept) + // First call to establish integrity (interactive auto-accept) const firstResult = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: true, }); - expect(firstResult.policyUpdateConfirmationRequest).toBeDefined(); - - // Establish integrity manually as if accepted - const { PolicyIntegrityManager } = await import('@google/gemini-cli-core'); - const integrityManager = new PolicyIntegrityManager(); - await integrityManager.acceptIntegrity( - 'workspace', - workspaceDir, - firstResult.policyUpdateConfirmationRequest!.newHash, - ); + expect(firstResult.workspacePoliciesDir).toBe(policiesDir); + expect(firstResult.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).not.toHaveBeenCalled(); // Second call should match + const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, @@ -107,26 +117,33 @@ describe('resolveWorkspacePolicyState', () => { expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); - it('should return confirmation request if changed in interactive mode', async () => { - fs.mkdirSync(policiesDir, { recursive: true }); - fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + it('should return confirmation request if changed in interactive mode when AUTO_ACCEPT is false', async () => { + const originalValue = autoAcceptWorkspacePolicies; + setAutoAcceptWorkspacePolicies(false); - const result = await resolveWorkspacePolicyState({ - cwd: workspaceDir, - trustedFolder: true, - interactive: true, - }); + try { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); - expect(result.workspacePoliciesDir).toBeUndefined(); - expect(result.policyUpdateConfirmationRequest).toEqual({ - scope: 'workspace', - identifier: workspaceDir, - policyDir: policiesDir, - newHash: expect.any(String), - }); + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toEqual({ + scope: 'workspace', + identifier: workspaceDir, + policyDir: policiesDir, + newHash: expect.any(String), + }); + } finally { + setAutoAcceptWorkspacePolicies(originalValue); + } }); - it('should warn and auto-accept if changed in non-interactive mode', async () => { + it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is true', async () => { fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); @@ -143,6 +160,30 @@ describe('resolveWorkspacePolicyState', () => { ); }); + it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is false', async () => { + const originalValue = autoAcceptWorkspacePolicies; + setAutoAcceptWorkspacePolicies(false); + + try { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: false, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).toHaveBeenCalledWith( + expect.stringContaining('Automatically accepting and loading'), + ); + } finally { + setAutoAcceptWorkspacePolicies(originalValue); + } + }); + it('should not return workspace policies if cwd is the home directory', async () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); @@ -159,7 +200,26 @@ describe('resolveWorkspacePolicyState', () => { expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); - it('should not return workspace policies if cwd is a symlink to the home directory', async () => { + it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => { + setDisableWorkspacePolicies(true); + + // Set up policies directory with a file + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result).toEqual({ + workspacePoliciesDir: undefined, + policyUpdateConfirmationRequest: undefined, + }); + }); + + it('should return empty state if cwd is a symlink to the home directory', async () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 3b85d0b4b6..bc22c928f8 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -17,9 +17,38 @@ import { Storage, type PolicyUpdateConfirmationRequest, writeToStderr, + debugLogger, } from '@google/gemini-cli-core'; import { type Settings } from './settings.js'; +/** + * Temporary flag to automatically accept workspace policies to reduce friction. + * Exported as 'let' to allow monkey patching in tests via the setter. + */ +export let autoAcceptWorkspacePolicies = true; + +/** + * Sets the autoAcceptWorkspacePolicies flag. + * Used primarily for testing purposes. + */ +export function setAutoAcceptWorkspacePolicies(value: boolean) { + autoAcceptWorkspacePolicies = value; +} + +/** + * Temporary flag to disable workspace level policies altogether. + * Exported as 'let' to allow monkey patching in tests via the setter. + */ +export let disableWorkspacePolicies = true; + +/** + * Sets the disableWorkspacePolicies flag. + * Used primarily for testing purposes. + */ +export function setDisableWorkspacePolicies(value: boolean) { + disableWorkspacePolicies = value; +} + export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, @@ -66,7 +95,7 @@ export async function resolveWorkspacePolicyState(options: { | PolicyUpdateConfirmationRequest | undefined; - if (trustedFolder) { + if (trustedFolder && !disableWorkspacePolicies) { const storage = new Storage(cwd); // If we are in the home directory (or rather, our target Gemini dir is the global one), @@ -91,8 +120,8 @@ export async function resolveWorkspacePolicyState(options: { ) { // No workspace policies found workspacePoliciesDir = undefined; - } else if (interactive) { - // Policies changed or are new, and we are in interactive mode + } else if (interactive && !autoAcceptWorkspacePolicies) { + // Policies changed or are new, and we are in interactive mode and auto-accept is disabled policyUpdateConfirmationRequest = { scope: 'workspace', identifier: cwd, @@ -100,17 +129,23 @@ export async function resolveWorkspacePolicyState(options: { newHash: integrityResult.hash, }; } else { - // Non-interactive mode: warn and automatically accept/load + // Non-interactive mode or auto-accept is enabled: automatically accept/load await integrityManager.acceptIntegrity( 'workspace', cwd, integrityResult.hash, ); workspacePoliciesDir = potentialWorkspacePoliciesDir; - // debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console - writeToStderr( - 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n', - ); + + if (!interactive) { + writeToStderr( + 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them.\n', + ); + } else { + debugLogger.warn( + 'Workspace policies changed or are new. Automatically accepting and loading them.', + ); + } } } diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index ffe1dd2ac5..17a916213f 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -96,6 +96,14 @@ describe('SettingsSchema', () => { ]); }); + it('should have errorVerbosity enum property', () => { + const definition = getSettingsSchema().ui?.properties?.errorVerbosity; + expect(definition).toBeDefined(); + expect(definition?.type).toBe('enum'); + expect(definition?.default).toBe('low'); + expect(definition?.options?.map((o) => o.value)).toEqual(['low', 'full']); + }); + it('should have checkpointing nested properties', () => { expect( getSettingsSchema().general?.properties?.checkpointing.properties @@ -444,6 +452,60 @@ describe('SettingsSchema', () => { expect(hookItemProperties.description).toBeDefined(); expect(hookItemProperties.description.type).toBe('string'); }); + + it('should have gemmaModelRouter setting in schema', () => { + const gemmaModelRouter = + getSettingsSchema().experimental.properties.gemmaModelRouter; + expect(gemmaModelRouter).toBeDefined(); + expect(gemmaModelRouter.type).toBe('object'); + expect(gemmaModelRouter.category).toBe('Experimental'); + expect(gemmaModelRouter.default).toEqual({}); + expect(gemmaModelRouter.requiresRestart).toBe(true); + expect(gemmaModelRouter.showInDialog).toBe(true); + expect(gemmaModelRouter.description).toBe( + 'Enable Gemma model router (experimental).', + ); + + const enabled = gemmaModelRouter.properties.enabled; + expect(enabled).toBeDefined(); + expect(enabled.type).toBe('boolean'); + expect(enabled.category).toBe('Experimental'); + expect(enabled.default).toBe(false); + expect(enabled.requiresRestart).toBe(true); + expect(enabled.showInDialog).toBe(true); + expect(enabled.description).toBe( + 'Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + ); + + const classifier = gemmaModelRouter.properties.classifier; + expect(classifier).toBeDefined(); + expect(classifier.type).toBe('object'); + expect(classifier.category).toBe('Experimental'); + expect(classifier.default).toEqual({}); + expect(classifier.requiresRestart).toBe(true); + expect(classifier.showInDialog).toBe(false); + expect(classifier.description).toBe('Classifier configuration.'); + + const host = classifier.properties.host; + expect(host).toBeDefined(); + expect(host.type).toBe('string'); + expect(host.category).toBe('Experimental'); + expect(host.default).toBe('http://localhost:9379'); + expect(host.requiresRestart).toBe(true); + expect(host.showInDialog).toBe(false); + expect(host.description).toBe('The host of the classifier.'); + + const model = classifier.properties.model; + expect(model).toBeDefined(); + expect(model.type).toBe('string'); + expect(model.category).toBe('Experimental'); + expect(model.default).toBe('gemma3-1b-gpu-custom'); + expect(model.requiresRestart).toBe(true); + expect(model.showInDialog).toBe(false); + expect(model.description).toBe( + 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', + ); + }); }); it('has JSON schema definitions for every referenced ref', () => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 26faaafda7..599c8e586b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -719,6 +719,20 @@ const SETTINGS_SCHEMA = { { value: 'off', label: 'Off' }, ], }, + errorVerbosity: { + type: 'enum', + label: 'Error Verbosity', + category: 'UI', + requiresRestart: false, + default: 'low', + description: + 'Controls whether recoverable errors are hidden (low) or fully shown (full).', + showInDialog: true, + options: [ + { value: 'low', label: 'Low' }, + { value: 'full', label: 'Full' }, + ], + }, customWittyPhrases: { type: 'array', label: 'Custom Witty Phrases', @@ -828,6 +842,36 @@ const SETTINGS_SCHEMA = { ref: 'TelemetrySettings', }, + billing: { + type: 'object', + label: 'Billing', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Billing and AI credits settings.', + showInDialog: false, + properties: { + overageStrategy: { + type: 'enum', + label: 'Overage Strategy', + category: 'Advanced', + requiresRestart: false, + default: 'ask', + description: oneLine` + How to handle quota exhaustion when AI credits are available. + 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + `, + showInDialog: true, + options: [ + { value: 'ask', label: 'Ask each time' }, + { value: 'always', label: 'Always use credits' }, + { value: 'never', label: 'Never use credits' }, + ], + }, + }, + }, + model: { type: 'object', label: 'Model', @@ -1787,6 +1831,57 @@ const SETTINGS_SCHEMA = { 'Enable web fetch behavior that bypasses LLM summarization.', showInDialog: true, }, + gemmaModelRouter: { + type: 'object', + label: 'Gemma Model Router', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Enable Gemma model router (experimental).', + showInDialog: true, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Gemma Model Router', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + showInDialog: true, + }, + classifier: { + type: 'object', + label: 'Classifier', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Classifier configuration.', + showInDialog: false, + properties: { + host: { + type: 'string', + label: 'Host', + category: 'Experimental', + requiresRestart: true, + default: 'http://localhost:9379', + description: 'The host of the classifier.', + showInDialog: false, + }, + model: { + type: 'string', + label: 'Model', + category: 'Experimental', + requiresRestart: true, + default: 'gemma3-1b-gpu-custom', + description: + 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', + showInDialog: false, + }, + }, + }, + }, + }, }, }, @@ -2532,7 +2627,9 @@ type InferSettings = { : T[K]['default'] : T[K]['default'] extends boolean ? boolean - : T[K]['default']; + : T[K]['default'] extends string + ? string + : T[K]['default']; }; type InferMergedSettings = { @@ -2544,7 +2641,9 @@ type InferMergedSettings = { : T[K]['default'] : T[K]['default'] extends boolean ? boolean - : T[K]['default']; + : T[K]['default'] extends string + ? string + : T[K]['default']; }; export type Settings = InferSettings; diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index 98cbe05bce..d0d98a5a31 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -10,6 +10,7 @@ import { loadCliConfig, type CliArgs } from './config.js'; import { createTestMergedSettings } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; +import * as Policy from './policy.js'; // Mock dependencies vi.mock('./trustedFolders.js', () => ({ @@ -53,6 +54,7 @@ describe('Workspace-Level Policy CLI Integration', () => { beforeEach(() => { vi.clearAllMocks(); + Policy.setDisableWorkspacePolicies(false); // Default to MATCH for existing tests mockCheckIntegrity.mockResolvedValue({ status: 'match', @@ -164,7 +166,7 @@ describe('Workspace-Level Policy CLI Integration', () => { ); }); - it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => { + it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in interactive mode when AUTO_ACCEPT is true', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', @@ -186,24 +188,23 @@ describe('Workspace-Level Policy CLI Integration', () => { cwd: MOCK_CWD, }); - expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ - scope: 'workspace', - identifier: MOCK_CWD, - policyDir: expect.stringContaining(path.join('.gemini', 'policies')), - newHash: 'new-hash', - }); - // In interactive mode without accept flag, it waits for user confirmation (handled by UI), - // so it currently DOES NOT pass the directory to createPolicyEngineConfig yet. - // The UI will handle the confirmation and reload/update. + expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined(); + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ - workspacePoliciesDir: undefined, + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), }), expect.anything(), ); }); - it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => { + it('should automatically accept and load workspacePoliciesDir if integrity is NEW in interactive mode when AUTO_ACCEPT is true', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', @@ -222,18 +223,65 @@ describe('Workspace-Level Policy CLI Integration', () => { cwd: MOCK_CWD, }); - expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ - scope: 'workspace', - identifier: MOCK_CWD, - policyDir: expect.stringContaining(path.join('.gemini', 'policies')), - newHash: 'new-hash', - }); + expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined(); + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ - workspacePoliciesDir: undefined, + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), }), expect.anything(), ); }); + + it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode when AUTO_ACCEPT is false', async () => { + // Monkey patch autoAcceptWorkspacePolicies using setter + const originalValue = Policy.autoAcceptWorkspacePolicies; + Policy.setAutoAcceptWorkspacePolicies(false); + + try { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { + query: 'test', + promptInteractive: 'test', + } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'workspace', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + } finally { + // Restore for other tests + Policy.setAutoAcceptWorkspacePolicies(originalValue); + } + }); }); diff --git a/packages/cli/src/core/auth.test.ts b/packages/cli/src/core/auth.test.ts index c844ee6f93..f28e826f49 100644 --- a/packages/cli/src/core/auth.test.ts +++ b/packages/cli/src/core/auth.test.ts @@ -17,7 +17,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...actual, - getErrorMessage: (e: unknown) => (e as Error).message, }; }); @@ -32,7 +31,7 @@ describe('auth', () => { it('should return null if authType is undefined', async () => { const result = await performInitialAuth(mockConfig, undefined); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).not.toHaveBeenCalled(); }); @@ -41,7 +40,7 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); @@ -54,7 +53,10 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBe('Failed to login. Message: Auth failed'); + expect(result).toEqual({ + authError: 'Failed to login. Message: Auth failed', + accountSuspensionInfo: null, + }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); @@ -68,7 +70,48 @@ describe('auth', () => { mockConfig, AuthType.LOGIN_WITH_GOOGLE, ); - expect(result).toBeNull(); + expect(result).toEqual({ authError: null, accountSuspensionInfo: null }); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => { + vi.mocked(mockConfig.refreshAuth).mockRejectedValue({ + response: { + data: { + error: { + code: 403, + message: + 'This service has been disabled for violation of Terms of Service.', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'TOS_VIOLATION', + domain: 'example.googleapis.com', + metadata: { + appeal_url: 'https://example.com/appeal', + appeal_url_link_text: 'Appeal Here', + }, + }, + ], + }, + }, + }, + }); + const result = await performInitialAuth( + mockConfig, + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result).toEqual({ + authError: null, + accountSuspensionInfo: { + message: + 'This service has been disabled for violation of Terms of Service.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', + }, + }); expect(mockConfig.refreshAuth).toHaveBeenCalledWith( AuthType.LOGIN_WITH_GOOGLE, ); diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts index 7b1e8c8277..f49fdecf76 100644 --- a/packages/cli/src/core/auth.ts +++ b/packages/cli/src/core/auth.ts @@ -9,20 +9,28 @@ import { type Config, getErrorMessage, ValidationRequiredError, + isAccountSuspendedError, } from '@google/gemini-cli-core'; +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; + +export interface InitialAuthResult { + authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; +} + /** * Handles the initial authentication flow. * @param config The application config. * @param authType The selected auth type. - * @returns An error message if authentication fails, otherwise null. + * @returns The auth result with error message and account suspension status. */ export async function performInitialAuth( config: Config, authType: AuthType | undefined, -): Promise { +): Promise { if (!authType) { - return null; + return { authError: null, accountSuspensionInfo: null }; } try { @@ -33,10 +41,24 @@ export async function performInitialAuth( if (e instanceof ValidationRequiredError) { // Don't treat validation required as a fatal auth error during startup. // This allows the React UI to load and show the ValidationDialog. - return null; + return { authError: null, accountSuspensionInfo: null }; } - return `Failed to login. Message: ${getErrorMessage(e)}`; + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + return { + authError: null, + accountSuspensionInfo: { + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }, + }; + } + return { + authError: `Failed to login. Message: ${getErrorMessage(e)}`, + accountSuspensionInfo: null, + }; } - return null; + return { authError: null, accountSuspensionInfo: null }; } diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 57f1c41551..e4fdb2cba5 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -72,7 +72,10 @@ describe('initializer', () => { vi.mocked(IdeClient.getInstance).mockResolvedValue( mockIdeClient as unknown as IdeClient, ); - vi.mocked(performInitialAuth).mockResolvedValue(null); + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: null, + accountSuspensionInfo: null, + }); vi.mocked(validateTheme).mockReturnValue(null); }); @@ -84,6 +87,7 @@ describe('initializer', () => { expect(result).toEqual({ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, @@ -103,6 +107,7 @@ describe('initializer', () => { expect(result).toEqual({ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 5, @@ -116,7 +121,10 @@ describe('initializer', () => { }); it('should handle auth error', async () => { - vi.mocked(performInitialAuth).mockResolvedValue('Auth failed'); + vi.mocked(performInitialAuth).mockResolvedValue({ + authError: 'Auth failed', + accountSuspensionInfo: null, + }); const result = await initializeApp( mockConfig as unknown as Config, mockSettings, diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index e99efd90f6..f27e9a9511 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -17,9 +17,11 @@ import { import { type LoadedSettings } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; +import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js'; export interface InitializationResult { authError: string | null; + accountSuspensionInfo: AccountSuspensionInfo | null; themeError: string | null; shouldOpenAuthDialog: boolean; geminiMdFileCount: number; @@ -37,7 +39,7 @@ export async function initializeApp( settings: LoadedSettings, ): Promise { const authHandle = startupProfiler.start('authenticate'); - const authError = await performInitialAuth( + const { authError, accountSuspensionInfo } = await performInitialAuth( config, settings.merged.security.auth.selectedType, ); @@ -60,6 +62,7 @@ export async function initializeApp( return { authError, + accountSuspensionInfo, themeError, shouldOpenAuthDialog, geminiMdFileCount: config.getGeminiMdFileCount(), diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 538fb8ee4e..2784c5694a 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1182,6 +1182,7 @@ describe('startInteractiveUI', () => { getProjectRoot: () => '/root', getScreenReader: () => false, getDebugMode: () => false, + getUseAlternateBuffer: () => true, }); const mockSettings = { merged: { @@ -1201,6 +1202,7 @@ describe('startInteractiveUI', () => { const mockWorkspaceRoot = '/root'; const mockInitializationResult = { authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, @@ -1216,6 +1218,8 @@ describe('startInteractiveUI', () => { runExitCleanup: vi.fn(), registerSyncCleanup: vi.fn(), registerTelemetryConfig: vi.fn(), + setupSignalHandlers: vi.fn(), + setupTtyCheck: vi.fn(() => vi.fn()), })); beforeEach(() => { @@ -1322,7 +1326,8 @@ describe('startInteractiveUI', () => { // Verify all startup tasks were called expect(getVersion).toHaveBeenCalledTimes(1); - expect(registerCleanup).toHaveBeenCalledTimes(4); + // 5 cleanups: mouseEvents, consolePatcher, lineWrapping, instance.unmount, and TTY check + expect(registerCleanup).toHaveBeenCalledTimes(5); // Verify cleanup handler is registered with unmount function const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0]; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index aa830c0250..2e238765e8 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -32,6 +32,8 @@ import { registerSyncCleanup, runExitCleanup, registerTelemetryConfig, + setupSignalHandlers, + setupTtyCheck, } from './utils/cleanup.js'; import { cleanupToolOutputFiles, @@ -100,8 +102,8 @@ import { loadSandboxConfig } from './config/sandboxConfig.js'; import { deleteSession, listSessions } from './utils/sessions.js'; import { createPolicyUpdater } from './config/policy.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; -import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { TerminalProvider } from './ui/contexts/TerminalContext.js'; +import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; @@ -194,7 +196,7 @@ export async function startInteractiveUI( // and the Ink alternate buffer mode requires line wrapping harmful to // screen readers. const useAlternateBuffer = shouldEnterAlternateScreen( - isAlternateBufferEnabled(settings), + isAlternateBufferEnabled(config), config.getScreenReader(), ); const mouseEventsEnabled = useAlternateBuffer; @@ -319,6 +321,8 @@ export async function startInteractiveUI( }); registerCleanup(() => instance.unmount()); + + registerCleanup(setupTtyCheck()); } export async function main() { @@ -340,6 +344,8 @@ export async function main() { setupUnhandledRejectionHandler(); + setupSignalHandlers(); + const slashCommandConflictHandler = new SlashCommandConflictHandler(); slashCommandConflictHandler.start(); registerCleanup(() => slashCommandConflictHandler.stop()); @@ -646,10 +652,7 @@ export async function main() { process.stdin.setRawMode(true); // This cleanup isn't strictly needed but may help in certain situations. - process.on('SIGTERM', () => { - process.stdin.setRawMode(wasRaw); - }); - process.on('SIGINT', () => { + registerSyncCleanup(() => { process.stdin.setRawMode(wasRaw); }); } @@ -675,7 +678,7 @@ export async function main() { let input = config.getQuestion(); const useAlternateBuffer = shouldEnterAlternateScreen( - isAlternateBufferEnabled(settings), + isAlternateBufferEnabled(config), config.getScreenReader(), ); const rawStartupWarnings = await getStartupWarnings(); diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 018ce1502b..3ff65c4067 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -29,6 +29,7 @@ import { createContentGenerator, IdeClient, debugLogger, + CoreToolCallStatus, } from '@google/gemini-cli-core'; import { type MockShellCommand, @@ -36,7 +37,47 @@ import { } from './MockShellExecutionService.js'; import { createMockSettings } from './settings.js'; import { type LoadedSettings } from '../config/settings.js'; -import { AuthState } from '../ui/types.js'; +import { AuthState, StreamingState } from '../ui/types.js'; +import { randomUUID } from 'node:crypto'; +import type { + TrackedCancelledToolCall, + TrackedCompletedToolCall, + TrackedToolCall, +} from '../ui/hooks/useToolScheduler.js'; + +// Global state observer for React-based signals +const sessionStateMap = new Map(); +const activeRigs = new Map(); + +// Mock StreamingContext to report state changes back to the observer +vi.mock('../ui/contexts/StreamingContext.js', async (importOriginal) => { + const original = + await importOriginal(); + const { useConfig } = await import('../ui/contexts/ConfigContext.js'); + const React = await import('react'); + + return { + ...original, + useStreamingContext: () => { + const state = original.useStreamingContext(); + const config = useConfig(); + const sessionId = config.getSessionId(); + + React.useEffect(() => { + sessionStateMap.set(sessionId, state); + // If we see activity, we are no longer "awaiting" the start of a response + if (state !== StreamingState.Idle) { + const rig = activeRigs.get(sessionId); + if (rig) { + rig.awaitingResponse = false; + } + } + }, [sessionId, state]); + + return state; + }, + }; +}); // Mock core functions globally for tests using AppRig. vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -63,6 +104,8 @@ vi.mock('../ui/auth/useAuth.js', () => ({ onAuthError: vi.fn(), apiKeyDefaultValue: 'test-api-key', reloadApiKey: vi.fn().mockResolvedValue('test-api-key'), + accountSuspensionInfo: null, + setAccountSuspensionInfo: vi.fn(), }), validateAuthMethodWithSettings: () => null, })); @@ -112,9 +155,18 @@ export class AppRig { private breakpointTools = new Set(); private lastAwaitedConfirmation: PendingConfirmation | undefined; + /** + * True if a message was just sent but React hasn't yet reported a non-idle state. + */ + awaitingResponse = false; + constructor(private options: AppRigOptions = {}) { - this.testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-app-rig-')); - this.sessionId = `test-session-${Math.random().toString(36).slice(2, 9)}`; + const uniqueId = randomUUID(); + this.testDir = fs.mkdtempSync( + path.join(os.tmpdir(), `gemini-app-rig-${uniqueId.slice(0, 8)}-`), + ); + this.sessionId = `test-session-${uniqueId}`; + activeRigs.set(this.sessionId, this); } async initialize() { @@ -245,6 +297,8 @@ export class AppRig { }; } + private toolCalls: TrackedToolCall[] = []; + private setupMessageBusListeners() { if (!this.config) return; const messageBus = this.config.getMessageBus(); @@ -252,6 +306,7 @@ export class AppRig { messageBus.subscribe( MessageBusType.TOOL_CALLS_UPDATE, (message: ToolCallsUpdateMessage) => { + this.toolCalls = message.toolCalls; for (const call of message.toolCalls) { if (call.status === 'awaiting_approval' && call.correlationId) { const details = call.confirmationDetails; @@ -281,6 +336,48 @@ export class AppRig { ); } + /** + * Returns true if the agent is currently busy (responding or executing tools). + */ + isBusy(): boolean { + if (this.awaitingResponse) { + return true; + } + + const reactState = sessionStateMap.get(this.sessionId); + // If we have a React-based state, use it as the definitive signal. + // 'responding' and 'waiting-for-confirmation' both count as busy for the overall task. + if (reactState !== undefined) { + return reactState !== StreamingState.Idle; + } + + // Fallback to tool tracking if React hasn't reported yet + const isAnyToolActive = this.toolCalls.some((tc) => { + if ( + tc.status === CoreToolCallStatus.Executing || + tc.status === CoreToolCallStatus.Scheduled || + tc.status === CoreToolCallStatus.Validating + ) { + return true; + } + if ( + tc.status === CoreToolCallStatus.Success || + tc.status === CoreToolCallStatus.Error || + tc.status === CoreToolCallStatus.Cancelled + ) { + return !(tc as TrackedCompletedToolCall | TrackedCancelledToolCall) + .responseSubmittedToGemini; + } + return false; + }); + + const isAwaitingConfirmation = this.toolCalls.some( + (tc) => tc.status === CoreToolCallStatus.AwaitingApproval, + ); + + return isAnyToolActive || isAwaitingConfirmation; + } + render() { if (!this.config || !this.settings) throw new Error('AppRig not initialized'); @@ -292,6 +389,7 @@ export class AppRig { version="test-version" initializationResult={{ authError: null, + accountSuspensionInfo: null, themeError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, @@ -334,17 +432,21 @@ export class AppRig { this.setBreakpoint(name); } } else { - this.setToolPolicy(toolName, PolicyDecision.ASK_USER, 100); + // Use undefined toolName to create a global rule if '*' is provided + const actualToolName = toolName === '*' ? undefined : toolName; + this.setToolPolicy(actualToolName, PolicyDecision.ASK_USER, 100); this.breakpointTools.add(toolName); } } removeToolPolicy(toolName?: string, source = 'AppRig Override') { if (!this.config) throw new Error('AppRig not initialized'); + // Map '*' back to undefined for policy removal + const actualToolName = toolName === '*' ? undefined : toolName; this.config .getPolicyEngine() // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - .removeRulesForTool(toolName as string, source); + .removeRulesForTool(actualToolName as string, source); this.breakpointTools.delete(toolName); } @@ -416,6 +518,44 @@ export class AppRig { return matched!; } + /** + * Waits for either a tool confirmation request OR for the agent to go idle. + */ + async waitForNextEvent( + timeout = 60000, + ): Promise< + | { type: 'confirmation'; confirmation: PendingConfirmation } + | { type: 'idle' } + > { + let confirmation: PendingConfirmation | undefined; + let isIdle = false; + + await this.waitUntil( + async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + confirmation = this.getPendingConfirmations()[0]; + // Now that we have a code-powered signal, this should be perfectly deterministic. + isIdle = !this.isBusy(); + return !!confirmation || isIdle; + }, + { + timeout, + message: 'Timed out waiting for next event (confirmation or idle).', + }, + ); + + if (confirmation) { + this.lastAwaitedConfirmation = confirmation; + return { type: 'confirmation', confirmation }; + } + + // Ensure all renders are flushed before returning 'idle' + await this.renderResult?.waitUntilReady(); + return { type: 'idle' }; + } + async resolveTool( toolNameOrDisplayName: string | RegExp | PendingConfirmation, outcome: ToolConfirmationOutcome = ToolConfirmationOutcome.ProceedOnce, @@ -471,6 +611,32 @@ export class AppRig { }); } + /** + * Drains all pending tool calls that hit a breakpoint until the agent is idle. + * Useful for negative tests to ensure no unwanted tools (like generalist) are called. + * + * @param onConfirmation Optional callback to inspect each confirmation before resolving. + * Return true to skip the default resolveTool call (e.g. if you handled it). + */ + async drainBreakpointsUntilIdle( + onConfirmation?: (confirmation: PendingConfirmation) => void | boolean, + timeout = 60000, + ) { + while (true) { + const event = await this.waitForNextEvent(timeout); + if (event.type === 'idle') { + break; + } + + const confirmation = event.confirmation; + const handled = onConfirmation?.(confirmation); + + if (!handled) { + await this.resolveTool(confirmation); + } + } + } + getConfig(): Config { if (!this.config) throw new Error('AppRig not initialized'); return this.config; @@ -530,11 +696,16 @@ export class AppRig { } async sendMessage(text: string) { + this.awaitingResponse = true; await this.type(text); await this.pressEnter(); } async unmount() { + // Clean up global state for this session + sessionStateMap.delete(this.sessionId); + activeRigs.delete(this.sessionId); + // Poison the chat recording service to prevent late writes to the test directory if (this.config) { const recordingService = this.config diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index af36444c39..8b7c7c520d 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -141,7 +141,10 @@ export const createMockConfig = (overrides: Partial = {}): Config => getMcpClientManager: vi.fn().mockReturnValue({ getMcpInstructions: vi.fn().mockReturnValue(''), getMcpServers: vi.fn().mockReturnValue({}), + getLastError: vi.fn().mockReturnValue(undefined), }), + setUserInteractedWithMcp: vi.fn(), + emitMcpDiagnostic: vi.fn(), getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false), getAdminSkillsEnabled: vi.fn().mockReturnValue(false), getDisabledSkills: vi.fn().mockReturnValue([]), @@ -156,6 +159,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getExperiments: vi.fn().mockReturnValue(undefined), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), validatePathAccess: vi.fn().mockReturnValue(null), + getUseAlternateBuffer: vi.fn().mockReturnValue(false), ...overrides, }) as unknown as Config; diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 455a84b8e0..6908fd36fb 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -547,6 +547,11 @@ const baseMockUiState = { }, hintMode: false, hintBuffer: '', + bannerData: { + defaultText: '', + warningText: '', + }, + bannerVisible: false, }; export const mockAppState: AppState = { @@ -586,6 +591,8 @@ const mockUIActions: UIActions = { handleClearScreen: vi.fn(), handleProQuotaChoice: vi.fn(), handleValidationChoice: vi.fn(), + handleOverageMenuChoice: vi.fn(), + handleEmptyWalletChoice: vi.fn(), setQueueErrorMessage: vi.fn(), popAllMessages: vi.fn(), handleApiKeySubmit: vi.fn(), @@ -607,6 +614,8 @@ const mockUIActions: UIActions = { onHintSubmit: vi.fn(), handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), + getPreferredEditor: vi.fn(), + clearAccountSuspension: vi.fn(), }; let capturedOverflowState: OverflowState | undefined; @@ -697,6 +706,21 @@ export const renderWithProviders = ( }); } + // 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 mainAreaWidth = terminalWidth; const finalUiState = { @@ -725,7 +749,7 @@ export const renderWithProviders = ( const renderResult = render( - + @@ -737,7 +761,7 @@ export const renderWithProviders = ( { }); }); + describe('Expansion Persistence', () => { + let rerender: () => void; + let unmount: () => void; + let stdin: ReturnType['stdin']; + + const setupExpansionPersistenceTest = async ( + HighPriorityChild?: React.FC, + ) => { + const getTree = () => ( + + + + + {HighPriorityChild && } + + + + ); + + const renderResult = render(getTree()); + stdin = renderResult.stdin; + await act(async () => { + vi.advanceTimersByTime(100); + }); + rerender = () => renderResult.rerender(getTree()); + unmount = () => renderResult.unmount(); + }; + + const writeStdin = async (sequence: string) => { + await act(async () => { + stdin.write(sequence); + // Advance timers to allow escape sequence parsing and broadcasting + vi.advanceTimersByTime(100); + }); + rerender(); + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should reset expansion when a key is NOT handled by anyone', async () => { + await setupExpansionPersistenceTest(); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + rerender(); + expect(capturedUIState.constrainHeight).toBe(false); + + // Press a random key that no one handles (hits Low priority fallback) + await writeStdin('x'); + + // Should be reset to true (collapsed) + expect(capturedUIState.constrainHeight).toBe(true); + + unmount(); + }); + + it('should toggle expansion when Ctrl+O is pressed', async () => { + await setupExpansionPersistenceTest(); + + // Initial state is collapsed + expect(capturedUIState.constrainHeight).toBe(true); + + // Press Ctrl+O to expand (Ctrl+O is sequence \x0f) + await writeStdin('\x0f'); + expect(capturedUIState.constrainHeight).toBe(false); + + // Press Ctrl+O again to collapse + await writeStdin('\x0f'); + expect(capturedUIState.constrainHeight).toBe(true); + + unmount(); + }); + + it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => { + const NavigationHandler = () => { + // use real useKeypress + useKeypress( + (key: Key) => { + if (key.name === 'up' || key.name === 'down') { + return true; // Handle navigation + } + return false; + }, + { isActive: true, priority: true }, // High priority + ); + return null; + }; + + await setupExpansionPersistenceTest(NavigationHandler); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + rerender(); + expect(capturedUIState.constrainHeight).toBe(false); + + // 1. Simulate Up arrow (handled by high priority child) + // CSI A is Up arrow + await writeStdin('\u001b[A'); + + // Should STILL be expanded + expect(capturedUIState.constrainHeight).toBe(false); + + // 2. Simulate Down arrow (handled by high priority child) + // CSI B is Down arrow + await writeStdin('\u001b[B'); + + // Should STILL be expanded + expect(capturedUIState.constrainHeight).toBe(false); + + // 3. Sanity check: press an unhandled key + await writeStdin('x'); + + // Should finally collapse + expect(capturedUIState.constrainHeight).toBe(true); + + unmount(); + }); + }); + describe('Shortcuts Help Visibility', () => { let handleGlobalKeypress: (key: Key) => boolean; let mockedUseKeypress: Mock; @@ -2675,6 +2805,10 @@ describe('AppContainer State Management', () => { isAlternateMode = false, childHandler?: Mock, ) => { + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue( + isAlternateMode, + ); + // Update settings for this test run const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const testSettings = { @@ -3364,6 +3498,8 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue([]); + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); + let unmount: () => void; await act(async () => { unmount = renderAppContainer({ @@ -3596,6 +3732,8 @@ describe('AppContainer State Management', () => { }, } as unknown as LoadedSettings; + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); + let unmount: () => void; await act(async () => { const result = renderAppContainer({ diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 86a4938a66..d42cad8495 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -47,6 +47,7 @@ import { type IdeInfo, type IdeContext, type UserTierId, + type GeminiUserTier, type UserFeedbackPayload, type AgentDefinition, type ApprovalMode, @@ -82,6 +83,8 @@ import { CoreToolCallStatus, generateSteeringAckMessage, buildUserSteeringHintPrompt, + logBillingEvent, + ApiKeyUpdatedEvent, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -145,7 +148,6 @@ import { useSessionResume } from './hooks/useSessionResume.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; -import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; @@ -228,7 +230,7 @@ export const AppContainer = (props: AppContainerProps) => { }); useMemoryMonitor(historyManager); - const isAlternateBuffer = useAlternateBuffer(); + const isAlternateBuffer = config.getUseAlternateBuffer(); const [corgiMode, setCorgiMode] = useState(false); const [forceRerenderKey, setForceRerenderKey] = useState(0); const [debugMessage, setDebugMessage] = useState(''); @@ -264,14 +266,16 @@ export const AppContainer = (props: AppContainerProps) => { () => isWorkspaceTrusted(settings.merged).isTrusted, ); - const [queueErrorMessage, setQueueErrorMessage] = useState( - null, + const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage( + QUEUE_ERROR_DISPLAY_DURATION_MS, ); const [newAgents, setNewAgents] = useState(null); const [constrainHeight, setConstrainHeight] = useState(true); - const [showIsExpandableHint, setShowIsExpandableHint] = useState(false); - const expandHintTimerRef = useRef(null); + const [expandHintTrigger, triggerExpandHint] = useTimedMessage( + EXPAND_HINT_DURATION_MS, + ); + const showIsExpandableHint = Boolean(expandHintTrigger); const overflowState = useOverflowState(); const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; @@ -284,39 +288,15 @@ export const AppContainer = (props: AppContainerProps) => { * boolean dependency (hasOverflowState) to ensure the timer only resets on * genuine state transitions, preventing it from infinitely resetting during * active text streaming. + * + * In alternate buffer mode, we don't trigger the hint automatically on overflow + * to avoid noise, but the user can still trigger it manually with Ctrl+O. */ useEffect(() => { - if (isAlternateBuffer) { - setShowIsExpandableHint(false); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - return; + if (hasOverflowState && !isAlternateBuffer) { + triggerExpandHint(true); } - - if (hasOverflowState) { - setShowIsExpandableHint(true); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - expandHintTimerRef.current = setTimeout(() => { - setShowIsExpandableHint(false); - }, EXPAND_HINT_DURATION_MS); - } - }, [hasOverflowState, isAlternateBuffer, constrainHeight]); - - /** - * Safe cleanup to ensure the expansion hint timer is cancelled when the - * component unmounts, preventing memory leaks. - */ - useEffect( - () => () => { - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - }, - [], - ); + }, [hasOverflowState, isAlternateBuffer, triggerExpandHint]); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); @@ -414,6 +394,9 @@ export const AppContainer = (props: AppContainerProps) => { ? { remaining, limit, resetTime } : undefined; }); + const [paidTier, setPaidTier] = useState( + undefined, + ); const [isConfigInitialized, setConfigInitialized] = useState(false); @@ -567,7 +550,7 @@ export const AppContainer = (props: AppContainerProps) => { const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } = useConsoleMessages(); - const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings); + const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config); // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = @@ -692,7 +675,14 @@ export const AppContainer = (props: AppContainerProps) => { onAuthError, apiKeyDefaultValue, reloadApiKey, - } = useAuthCommand(settings, config, initializationResult.authError); + accountSuspensionInfo, + setAccountSuspensionInfo, + } = useAuthCommand( + settings, + config, + initializationResult.authError, + initializationResult.accountSuspensionInfo, + ); const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>( {}, ); @@ -709,12 +699,20 @@ export const AppContainer = (props: AppContainerProps) => { handleProQuotaChoice, validationRequest, handleValidationChoice, + // G1 AI Credits + overageMenuRequest, + handleOverageMenuChoice, + emptyWalletRequest, + handleEmptyWalletChoice, } = useQuotaAndFallback({ config, historyManager, userTier, + paidTier, + settings, setModelSwitchedFromQuotaError, onShowAuthSelection: () => setAuthState(AuthState.Updating), + errorVerbosity: settings.merged.ui.errorVerbosity, }); // Derive auth state variables for backward compatibility with UIStateContext @@ -752,6 +750,8 @@ export const AppContainer = (props: AppContainerProps) => { const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { if (authType) { + const previousAuthType = + config.getContentGeneratorConfig()?.authType ?? 'unknown'; if (authType === AuthType.LOGIN_WITH_GOOGLE) { setAuthContext({ requiresRestart: true }); } else { @@ -764,6 +764,10 @@ export const AppContainer = (props: AppContainerProps) => { config.setRemoteAdminSettings(undefined); await config.refreshAuth(authType); setAuthState(AuthState.Authenticated); + logBillingEvent( + config, + new ApiKeyUpdatedEvent(previousAuthType, authType), + ); } catch (e) { if (e instanceof ChangeAuthRequestedError) { return; @@ -826,6 +830,7 @@ Logging in with Google... Restarting Gemini CLI to continue. // Only sync when not currently authenticating if (authState === AuthState.Authenticated) { setUserTier(config.getUserTier()); + setPaidTier(config.getUserPaidTier()); } }, [config, authState]); @@ -1252,10 +1257,7 @@ Logging in with Google... Restarting Gemini CLI to continue. async (submittedValue: string) => { reset(); // Explicitly hide the expansion hint and clear its x-second timer when a new turn begins. - setShowIsExpandableHint(false); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } + triggerExpandHint(null); if (!constrainHeight) { setConstrainHeight(true); if (!isAlternateBuffer) { @@ -1327,16 +1329,14 @@ Logging in with Google... Restarting Gemini CLI to continue. refreshStatic, reset, handleHintSubmit, + triggerExpandHint, ], ); const handleClearScreen = useCallback(() => { reset(); // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. - setShowIsExpandableHint(false); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } + triggerExpandHint(null); historyManager.clearItems(); clearConsoleMessagesState(); refreshStatic(); @@ -1345,7 +1345,7 @@ Logging in with Google... Restarting Gemini CLI to continue. clearConsoleMessagesState, refreshStatic, reset, - setShowIsExpandableHint, + triggerExpandHint, ]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); @@ -1632,17 +1632,6 @@ Logging in with Google... Restarting Gemini CLI to continue. } }, [ideNeedsRestart]); - useEffect(() => { - if (queueErrorMessage) { - const timer = setTimeout(() => { - setQueueErrorMessage(null); - }, QUEUE_ERROR_DISPLAY_DURATION_MS); - - return () => clearTimeout(timer); - } - return undefined; - }, [queueErrorMessage, setQueueErrorMessage]); - useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; @@ -1700,6 +1689,7 @@ Logging in with Google... Restarting Gemini CLI to continue. retryStatus, loadingPhrasesMode: settings.merged.ui.loadingPhrases, customWittyPhrases: settings.merged.ui.customWittyPhrases, + errorVerbosity: settings.merged.ui.errorVerbosity, }); const handleGlobalKeypress = useCallback( @@ -1748,13 +1738,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setConstrainHeight(true); if (keyMatchers[Command.SHOW_MORE_LINES](key)) { // If the user manually collapses the view, show the hint and reset the x-second timer. - setShowIsExpandableHint(true); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - expandHintTimerRef.current = setTimeout(() => { - setShowIsExpandableHint(false); - }, EXPAND_HINT_DURATION_MS); + triggerExpandHint(true); } if (!isAlternateBuffer) { refreshStatic(); @@ -1803,13 +1787,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ) { setConstrainHeight(false); // If the user manually expands the view, show the hint and reset the x-second timer. - setShowIsExpandableHint(true); - if (expandHintTimerRef.current) { - clearTimeout(expandHintTimerRef.current); - } - expandHintTimerRef.current = setTimeout(() => { - setShowIsExpandableHint(false); - }, EXPAND_HINT_DURATION_MS); + triggerExpandHint(true); if (!isAlternateBuffer) { refreshStatic(); } @@ -1914,10 +1892,14 @@ Logging in with Google... Restarting Gemini CLI to continue. showTransientMessage, settings.merged.general.devtools, showErrorDetails, + triggerExpandHint, ], ); - useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); + useKeypress(handleGlobalKeypress, { + isActive: true, + priority: KeypressPriority.Low, + }); useKeypress( () => { @@ -2056,6 +2038,8 @@ Logging in with Google... Restarting Gemini CLI to continue. showIdeRestartPrompt || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || isSessionBrowserOpen || authState === AuthState.AwaitingApiKeyInput || !!newAgents; @@ -2083,6 +2067,8 @@ Logging in with Google... Restarting Gemini CLI to continue. hasLoopDetectionConfirmationRequest || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || !!customDialog; const allowPlanMode = @@ -2223,6 +2209,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, apiKeyDefaultValue, @@ -2293,6 +2280,9 @@ Logging in with Google... Restarting Gemini CLI to continue. stats: quotaStats, proQuotaRequest, validationRequest, + // G1 AI Credits dialog state + overageMenuRequest, + emptyWalletRequest, }, contextFileNames, errorCount, @@ -2351,6 +2341,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, editorError, isEditorDialogOpen, @@ -2417,6 +2408,8 @@ Logging in with Google... Restarting Gemini CLI to continue. quotaStats, proQuotaRequest, validationRequest, + overageMenuRequest, + emptyWalletRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -2498,6 +2491,9 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + // G1 AI Credits handlers + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -2554,6 +2550,11 @@ Logging in with Google... Restarting Gemini CLI to continue. } setNewAgents(null); }, + getPreferredEditor, + clearAccountSuspension: () => { + setAccountSuspensionInfo(null); + setAuthState(AuthState.Updating); + }, }), [ handleThemeSelect, @@ -2583,6 +2584,8 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -2602,9 +2605,11 @@ Logging in with Google... Restarting Gemini CLI to continue. setActiveBackgroundShellPid, setIsBackgroundShellListOpen, setAuthContext, + setAccountSuspensionInfo, newAgents, config, historyManager, + getPreferredEditor, ], ); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx new file mode 100644 index 0000000000..692b249415 --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { BannedAccountDialog } from './BannedAccountDialog.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { Text } from 'ink'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + openBrowserSecurely: vi.fn(), + shouldLaunchBrowser: vi.fn().mockReturnValue(true), + }; +}); + +vi.mock('../../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: vi.fn(({ items }) => ( + <> + {items.map((item: { value: string; label: string }) => ( + {item.label} + ))} + + )), +})); + +const mockedRadioButtonSelect = RadioButtonSelect as Mock; +const mockedUseKeypress = useKeypress as Mock; +const mockedOpenBrowser = openBrowserSecurely as Mock; +const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock; +const mockedRunExitCleanup = runExitCleanup as Mock; + +const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = { + message: + 'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', +}; + +describe('BannedAccountDialog', () => { + let onExit: Mock; + let onChangeAuth: Mock; + + beforeEach(() => { + vi.resetAllMocks(); + mockedShouldLaunchBrowser.mockReturnValue(true); + mockedOpenBrowser.mockResolvedValue(undefined); + mockedRunExitCleanup.mockResolvedValue(undefined); + onExit = vi.fn(); + onChangeAuth = vi.fn(); + }); + + it('renders the suspension message from accountSuspensionInfo', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const frame = lastFrame(); + expect(frame).toContain('Account Suspended'); + expect(frame).toContain('violation of Terms of Service'); + expect(frame).toContain('Escape to exit'); + unmount(); + }); + + it('renders menu options with appeal link text from response', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(3); + expect(items[0].label).toBe('Appeal Here'); + expect(items[1].label).toBe('Change authentication'); + expect(items[2].label).toBe('Exit'); + unmount(); + }); + + it('hides form option when no appealUrl is provided', async () => { + const infoWithoutUrl: AccountSuspensionInfo = { + message: 'Account suspended.', + }; + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(2); + expect(items[0].label).toBe('Change authentication'); + expect(items[1].label).toBe('Exit'); + unmount(); + }); + + it('uses default label when appealLinkText is not provided', async () => { + const infoWithoutLinkText: AccountSuspensionInfo = { + message: 'Account suspended.', + appealUrl: 'https://example.com/appeal', + }; + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items[0].label).toBe('Open the Google Form'); + unmount(); + }); + + it('opens browser when appeal option is selected', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('open_form'); + expect(mockedOpenBrowser).toHaveBeenCalledWith( + 'https://example.com/appeal', + ); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('shows URL when browser cannot be launched', async () => { + mockedShouldLaunchBrowser.mockReturnValue(false); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('open_form'); + await waitFor(() => { + expect(lastFrame()).toContain('Please open this URL in a browser'); + }); + expect(mockedOpenBrowser).not.toHaveBeenCalled(); + unmount(); + }); + + it('calls onExit when "Exit" is selected', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('exit'); + expect(mockedRunExitCleanup).toHaveBeenCalled(); + expect(onExit).toHaveBeenCalled(); + unmount(); + }); + + it('calls onChangeAuth when "Change authentication" is selected', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('change_auth'); + expect(onChangeAuth).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('exits on escape key', async () => { + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + const result = keypressHandler({ name: 'escape' }); + expect(result).toBe(true); + unmount(); + }); + + it('renders snapshot correctly', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.tsx new file mode 100644 index 0000000000..e051ba082b --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.tsx @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +interface BannedAccountDialogProps { + accountSuspensionInfo: AccountSuspensionInfo; + onExit: () => void; + onChangeAuth: () => void; +} + +export function BannedAccountDialog({ + accountSuspensionInfo, + onExit, + onChangeAuth, +}: BannedAccountDialogProps): React.JSX.Element { + const [errorMessage, setErrorMessage] = useState(null); + + const appealUrl = accountSuspensionInfo.appealUrl; + const appealLinkText = + accountSuspensionInfo.appealLinkText ?? 'Open the Google Form'; + + const items = useMemo(() => { + const menuItems = []; + if (appealUrl) { + menuItems.push({ + label: appealLinkText, + value: 'open_form' as const, + key: 'open_form', + }); + } + menuItems.push( + { + label: 'Change authentication', + value: 'change_auth' as const, + key: 'change_auth', + }, + { + label: 'Exit', + value: 'exit' as const, + key: 'exit', + }, + ); + return menuItems; + }, [appealUrl, appealLinkText]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + void handleExit(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const handleExit = useCallback(async () => { + await runExitCleanup(); + onExit(); + }, [onExit]); + + const handleSelect = useCallback( + async (choice: string) => { + if (choice === 'open_form' && appealUrl) { + if (!shouldLaunchBrowser()) { + setErrorMessage(`Please open this URL in a browser: ${appealUrl}`); + return; + } + + try { + await openBrowserSecurely(appealUrl); + } catch { + setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`); + } + } else if (choice === 'change_auth') { + onChangeAuth(); + } else { + await handleExit(); + } + }, + [handleExit, onChangeAuth, appealUrl], + ); + + return ( + + + Error: Account Suspended + + + + {accountSuspensionInfo.message} + + + {appealUrl && ( + <> + + Appeal URL: + + + [{appealUrl}] + + + )} + + {errorMessage && ( + + {errorMessage} + + )} + + + void handleSelect(choice)} + /> + + + + Escape to exit + + + ); +} diff --git a/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap new file mode 100644 index 0000000000..b95994692d --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/BannedAccountDialog.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`BannedAccountDialog > renders snapshot correctly 1`] = ` +" + Error: Account Suspended + + This service has been disabled in this account for violation of Terms of Service. Please submit an + appeal to continue using this product. + + Appeal URL: + [https://example.com/appeal] + + Appeal HereChange authenticationExit + + Escape to exit +" +`; diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index effb17cdff..3faec2d5a8 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -11,6 +11,7 @@ import { type Config, loadApiKey, debugLogger, + isAccountSuspendedError, } from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; @@ -34,16 +35,21 @@ export function validateAuthMethodWithSettings( return validateAuthMethod(authType); } +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + export const useAuthCommand = ( settings: LoadedSettings, config: Config, initialAuthError: string | null = null, + initialAccountSuspensionInfo: AccountSuspensionInfo | null = null, ) => { const [authState, setAuthState] = useState( initialAuthError ? AuthState.Updating : AuthState.Unauthenticated, ); const [authError, setAuthError] = useState(initialAuthError); + const [accountSuspensionInfo, setAccountSuspensionInfo] = + useState(initialAccountSuspensionInfo); const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< string | undefined >(undefined); @@ -130,7 +136,16 @@ export const useAuthCommand = ( setAuthError(null); setAuthState(AuthState.Authenticated); } catch (e) { - onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + setAccountSuspensionInfo({ + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }); + } else { + onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); + } } })(); }, [ @@ -150,5 +165,7 @@ export const useAuthCommand = ( onAuthError, apiKeyDefaultValue, reloadApiKey, + accountSuspensionInfo, + setAccountSuspensionInfo, }; }; diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index ecce5c9cd5..3acace0774 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mcpCommand } from './mcpCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { @@ -77,6 +77,8 @@ describe('mcpCommand', () => { getGeminiClient: ReturnType; getMcpClientManager: ReturnType; getResourceRegistry: ReturnType; + setUserInteractedWithMcp: ReturnType; + getLastMcpError: ReturnType; }; beforeEach(() => { @@ -104,12 +106,15 @@ describe('mcpCommand', () => { }), getGeminiClient: vi.fn(), getMcpClientManager: vi.fn().mockImplementation(() => ({ - getBlockedMcpServers: vi.fn(), - getMcpServers: vi.fn(), + getBlockedMcpServers: vi.fn().mockReturnValue([]), + getMcpServers: vi.fn().mockReturnValue({}), + getLastError: vi.fn().mockReturnValue(undefined), })), getResourceRegistry: vi.fn().mockReturnValue({ getAllResources: vi.fn().mockReturnValue([]), }), + setUserInteractedWithMcp: vi.fn(), + getLastMcpError: vi.fn().mockReturnValue(undefined), }; mockContext = createMockCommandContext({ @@ -119,6 +124,10 @@ describe('mcpCommand', () => { }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('basic functionality', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ @@ -161,6 +170,7 @@ describe('mcpCommand', () => { mockConfig.getMcpClientManager = vi.fn().mockReturnValue({ getMcpServers: vi.fn().mockReturnValue(mockMcpServers), getBlockedMcpServers: vi.fn().mockReturnValue([]), + getLastError: vi.fn().mockReturnValue(undefined), }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 6b5e7d120c..e488db780f 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -52,6 +52,8 @@ const authCommand: SlashCommand = { }; } + config.setUserInteractedWithMcp(); + const mcpServers = config.getMcpClientManager()?.getMcpServers() ?? {}; if (!serverName) { @@ -184,6 +186,8 @@ const listAction = async ( }; } + config.setUserInteractedWithMcp(); + const toolRegistry = config.getToolRegistry(); if (!toolRegistry) { return { @@ -250,6 +254,13 @@ const listAction = async ( enablementState[serverName] = await enablementManager.getDisplayState(serverName); } + const errors: Record = {}; + for (const serverName of serverNames) { + const error = config.getMcpClientManager()?.getLastError(serverName); + if (error) { + errors[serverName] = error; + } + } const mcpStatusItem: HistoryItemMcpStatus = { type: MessageType.MCP_STATUS, @@ -274,16 +285,19 @@ const listAction = async ( })), authStatus, enablementState, - blockedServers: blockedMcpServers, + errors, + blockedServers: blockedMcpServers.map((s) => ({ + name: s.name, + extensionName: s.extensionName, + })), discoveryInProgress, connectingServers, - showDescriptions, - showSchema, + showDescriptions: Boolean(showDescriptions), + showSchema: Boolean(showSchema), }; context.ui.addItem(mcpStatusItem); }; - const listCommand: SlashCommand = { name: 'list', altNames: ['ls', 'nodesc', 'nodescription'], @@ -372,6 +386,8 @@ async function handleEnableDisable( }; } + config.setUserInteractedWithMcp(); + const parts = args.trim().split(/\s+/); const isSession = parts.includes('--session'); const serverName = parts.filter((p) => p !== '--session')[0]; diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 63fe3eb9e5..2f36c333b9 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -39,11 +39,18 @@ describe('statsCommand', () => { mockContext.session.stats.sessionStartTime = startTime; }); - it('should display general session stats when run with no subcommand', () => { + it('should display general session stats when run with no subcommand', async () => { if (!statsCommand.action) throw new Error('Command has no action'); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - statsCommand.action(mockContext, ''); + mockContext.services.config = { + refreshUserQuota: vi.fn(), + refreshAvailableCredits: vi.fn(), + getUserTierName: vi.fn(), + getUserPaidTier: vi.fn(), + getModel: vi.fn(), + } as unknown as Config; + + await statsCommand.action(mockContext, ''); const expectedDuration = formatDuration( endTime.getTime() - startTime.getTime(), @@ -55,6 +62,7 @@ describe('statsCommand', () => { tier: undefined, userEmail: 'mock@example.com', currentModel: undefined, + creditBalance: undefined, }); }); @@ -78,6 +86,8 @@ describe('statsCommand', () => { getQuotaRemaining: mockGetQuotaRemaining, getQuotaLimit: mockGetQuotaLimit, getQuotaResetTime: mockGetQuotaResetTime, + getUserPaidTier: vi.fn(), + refreshAvailableCredits: vi.fn(), } as unknown as Config; await statsCommand.action(mockContext, ''); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index b90e7309e1..1ded006618 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -11,7 +11,10 @@ import type { } from '../types.js'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; -import { UserAccountManager } from '@google/gemini-cli-core'; +import { + UserAccountManager, + getG1CreditBalance, +} from '@google/gemini-cli-core'; import { type CommandContext, type SlashCommand, @@ -27,8 +30,10 @@ function getUserIdentity(context: CommandContext) { const userEmail = cachedAccount ?? undefined; const tier = context.services.config?.getUserTierName(); + const paidTier = context.services.config?.getUserPaidTier(); + const creditBalance = getG1CreditBalance(paidTier) ?? undefined; - return { selectedAuthType, userEmail, tier }; + return { selectedAuthType, userEmail, tier, creditBalance }; } async function defaultSessionView(context: CommandContext) { @@ -43,7 +48,8 @@ async function defaultSessionView(context: CommandContext) { } const wallDuration = now.getTime() - sessionStartTime.getTime(); - const { selectedAuthType, userEmail, tier } = getUserIdentity(context); + const { selectedAuthType, userEmail, tier, creditBalance } = + getUserIdentity(context); const currentModel = context.services.config?.getModel(); const statsItem: HistoryItemStats = { @@ -53,10 +59,14 @@ async function defaultSessionView(context: CommandContext) { userEmail, tier, currentModel, + creditBalance, }; if (context.services.config) { - const quota = await context.services.config.refreshUserQuota(); + const [quota] = await Promise.all([ + context.services.config.refreshUserQuota(), + context.services.config.refreshAvailableCredits(), + ]); if (quota) { statsItem.quotas = quota; statsItem.pooledRemaining = context.services.config.getQuotaRemaining(); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 1d31b1a1f4..9606513510 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -183,6 +183,10 @@ interface AskUserDialogProps { * Height constraint for scrollable content. */ availableHeight?: number; + /** + * Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) + */ + extraParts?: string[]; } interface ReviewViewProps { @@ -190,6 +194,7 @@ interface ReviewViewProps { answers: { [key: string]: string }; onSubmit: () => void; progressHeader?: React.ReactNode; + extraParts?: string[]; } const ReviewView: React.FC = ({ @@ -197,6 +202,7 @@ const ReviewView: React.FC = ({ answers, onSubmit, progressHeader, + extraParts, }) => { const unansweredCount = questions.length - Object.keys(answers).length; const hasUnanswered = unansweredCount > 0; @@ -247,6 +253,7 @@ const ReviewView: React.FC = ({ ); @@ -925,6 +932,7 @@ export const AskUserDialog: React.FC = ({ onActiveTextInputChange, width, availableHeight: availableHeightProp, + extraParts, }) => { const uiState = useContext(UIStateContext); const availableHeight = @@ -1120,6 +1128,7 @@ export const AskUserDialog: React.FC = ({ answers={answers} onSubmit={handleReviewSubmit} progressHeader={progressHeader} + extraParts={extraParts} /> ); @@ -1143,6 +1152,7 @@ export const AskUserDialog: React.FC = ({ ? undefined : '↑/↓ to navigate' } + extraParts={extraParts} /> ); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 12deda3e76..999b1531f9 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -6,8 +6,8 @@ import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import { render } from '../../test-utils/render.js'; +import { act, useEffect } from 'react'; import { Box, Text } from 'ink'; -import { useEffect } from 'react'; import { Composer } from './Composer.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { @@ -34,6 +34,7 @@ import { StreamingState } from '../types.js'; import { TransientMessageType } from '../../utils/events.js'; import type { LoadedSettings } from '../../config/settings.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; +import type { TextBuffer } from './shared/text-buffer.js'; const composerTestControls = vi.hoisted(() => ({ suggestionsVisible: false, @@ -263,16 +264,26 @@ const renderComposer = async ( , ); await result.waitUntilReady(); + + // Wait for shortcuts hint debounce if using fake timers + if (vi.isFakeTimers()) { + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + } + return result; }; describe('Composer', () => { beforeEach(() => { + vi.useFakeTimers(); composerTestControls.suggestionsVisible = false; composerTestControls.isAlternateBuffer = false; }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -391,7 +402,7 @@ describe('Composer', () => { expect(output).not.toContain('ShortcutsHint'); }); - it('renders LoadingIndicator without thought when loadingPhrases is off', async () => { + it('renders LoadingIndicator with thought when loadingPhrases is off', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Hidden', description: 'Should not show' }, @@ -404,7 +415,7 @@ describe('Composer', () => { const output = lastFrame(); expect(output).toContain('LoadingIndicator'); - expect(output).not.toContain('Should not show'); + expect(output).toContain('LoadingIndicator: Hidden'); }); it('does not render LoadingIndicator when waiting for confirmation', async () => { @@ -809,6 +820,28 @@ describe('Composer', () => { }); describe('Shortcuts Hint', () => { + it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => { + const { lastFrame } = await renderComposer( + createMockUIState({ + buffer: { text: '' } as unknown as TextBuffer, + cleanUiDetailsVisible: false, + }), + ); + + expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint'); + }); + + it('does not show shortcuts hint immediately when buffer has text', async () => { + const uiState = createMockUIState({ + buffer: { text: 'hello' } as unknown as TextBuffer, + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + it('hides shortcuts hint when showShortcutsHint setting is false', async () => { const uiState = createMockUIState(); const settings = createMockSettings({ @@ -857,6 +890,27 @@ describe('Composer', () => { expect(lastFrame()).toContain('ShortcutsHint'); }); + it('hides shortcuts hint while loading when full UI details are visible', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: true, + streamingState: StreamingState.Responding, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + + it('hides shortcuts hint when text is typed in buffer', async () => { + const uiState = createMockUIState({ + buffer: { text: 'hello' } as unknown as TextBuffer, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + it('hides shortcuts hint while loading in minimal mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, @@ -930,9 +984,10 @@ describe('Composer', () => { streamingState: StreamingState.Idle, }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHelp'); + unmount(); }); it('hides shortcuts help while streaming', async () => { @@ -941,9 +996,10 @@ describe('Composer', () => { streamingState: StreamingState.Responding, }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); + unmount(); }); it('hides shortcuts help when action is required', async () => { @@ -956,9 +1012,10 @@ describe('Composer', () => { ), }); - const { lastFrame } = await renderComposer(uiState); + const { lastFrame, unmount } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 2adc370ed5..51c879e772 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -151,11 +151,30 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { : undefined, ); const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions; + const isModelIdle = uiState.streamingState === StreamingState.Idle; + const isBufferEmpty = uiState.buffer.text.length === 0; + const canShowShortcutsHint = + isModelIdle && isBufferEmpty && !hasPendingActionRequired; + const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] = + useState(canShowShortcutsHint); + + useEffect(() => { + if (!canShowShortcutsHint) { + setShowShortcutsHintDebounced(false); + return; + } + + const timeout = setTimeout(() => { + setShowShortcutsHintDebounced(true); + }, 200); + + return () => clearTimeout(timeout); + }, [canShowShortcutsHint]); + const showShortcutsHint = settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions && - !hideMinimalModeHintWhileBusy && - !hasPendingActionRequired; + showShortcutsHintDebounced; const showMinimalModeBleedThrough = !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; @@ -210,8 +229,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { inline thought={ uiState.streamingState === - StreamingState.WaitingForConfirmation || - settings.merged.ui.loadingPhrases === 'off' + StreamingState.WaitingForConfirmation ? undefined : uiState.thought } @@ -254,8 +272,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { inline thought={ uiState.streamingState === - StreamingState.WaitingForConfirmation || - settings.merged.ui.loadingPhrases === 'off' + StreamingState.WaitingForConfirmation ? undefined : uiState.thought } diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index 108db073d5..6e6a4ce48c 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { describe, it, expect, vi } from 'vitest'; import type { ConsoleMessageItem } from '../types.js'; import { Box } from 'ink'; import type React from 'react'; +import { createMockSettings } from '../../test-utils/settings.js'; vi.mock('./shared/ScrollableList.js', () => ({ ScrollableList: ({ @@ -29,13 +30,18 @@ vi.mock('./shared/ScrollableList.js', () => ({ describe('DetailedMessagesDisplay', () => { it('renders nothing when messages are empty', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), + }, ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); @@ -50,13 +56,18 @@ describe('DetailedMessagesDisplay', () => { { type: 'debug', content: 'Debug message', count: 1 }, ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); @@ -65,18 +76,69 @@ describe('DetailedMessagesDisplay', () => { unmount(); }); + it('hides the F12 hint in low error verbosity mode', async () => { + const messages: ConsoleMessageItem[] = [ + { type: 'error', content: 'Error message', count: 1 }, + ]; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'low' } }, + }), + }, + ); + await waitUntilReady(); + expect(lastFrame()).not.toContain('(F12 to close)'); + unmount(); + }); + + it('shows the F12 hint in full error verbosity mode', async () => { + const messages: ConsoleMessageItem[] = [ + { type: 'error', content: 'Error message', count: 1 }, + ]; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), + }, + ); + await waitUntilReady(); + expect(lastFrame()).toContain('(F12 to close)'); + unmount(); + }); + it('renders message counts', async () => { const messages: ConsoleMessageItem[] = [ { type: 'log', content: 'Repeated message', count: 5 }, ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), + }, ); await waitUntilReady(); const output = lastFrame(); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index ff88afa888..097ebe1378 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -13,6 +13,8 @@ import { ScrollableList, type ScrollableListRef, } from './shared/ScrollableList.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; interface DetailedMessagesDisplayProps { messages: ConsoleMessageItem[]; @@ -27,6 +29,10 @@ export const DetailedMessagesDisplay: React.FC< DetailedMessagesDisplayProps > = ({ messages, maxHeight, width, hasFocus }) => { const scrollableListRef = useRef>(null); + const config = useConfig(); + const settings = useSettings(); + const showHotkeyHint = + settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode(); const borderAndPadding = 3; @@ -65,7 +71,10 @@ export const DetailedMessagesDisplay: React.FC< > - Debug Console (F12 to close) + Debug Console{' '} + {showHotkeyHint && ( + (F12 to close) + )} diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 2dbdd5019b..6329ca89a1 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -80,6 +80,8 @@ describe('DialogManager', () => { stats: undefined, proQuotaRequest: null, validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, }, shouldShowIdePrompt: false, isFolderTrustDialogOpen: false, @@ -132,6 +134,8 @@ describe('DialogManager', () => { resolve: vi.fn(), }, validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, }, }, 'ProQuotaDialog', diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 3d56c68e5b..f7f050a53f 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -13,11 +13,14 @@ import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; import { AuthInProgress } from '../auth/AuthInProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; +import { BannedAccountDialog } from '../auth/BannedAccountDialog.js'; import { ApiAuthDialog } from '../auth/ApiAuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; import { ValidationDialog } from './ValidationDialog.js'; +import { OverageMenuDialog } from './OverageMenuDialog.js'; +import { EmptyWalletDialog } from './EmptyWalletDialog.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; import { SessionBrowser } from './SessionBrowser.js'; @@ -135,6 +138,7 @@ export const DialogManager = ({ isModelNotFoundError={ !!uiState.quota.proQuotaRequest.isModelNotFoundError } + authType={uiState.quota.proQuotaRequest.authType} onChoice={uiActions.handleProQuotaChoice} /> ); @@ -151,6 +155,28 @@ export const DialogManager = ({ /> ); } + if (uiState.quota.overageMenuRequest) { + return ( + + ); + } + if (uiState.quota.emptyWalletRequest) { + return ( + + ); + } if (uiState.shouldShowIdePrompt) { return ( ); } + if (uiState.accountSuspensionInfo) { + return ( + + { + process.exit(1); + }} + onChangeAuth={() => { + uiActions.clearAccountSuspension(); + }} + /> + + ); + } if (uiState.isAuthenticating) { return ( ); } + if (uiState.isAuthDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx new file mode 100644 index 0000000000..6f8f063c43 --- /dev/null +++ b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx @@ -0,0 +1,218 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { act } from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EmptyWalletDialog } from './EmptyWalletDialog.js'; + +const writeKey = (stdin: { write: (data: string) => void }, key: string) => { + act(() => { + stdin.write(key); + }); +}; + +describe('EmptyWalletDialog', () => { + const mockOnChoice = vi.fn(); + const mockOnGetCredits = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('rendering', () => { + it('should match snapshot with fallback available', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should match snapshot without fallback', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should display the model name and usage limit message', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('gemini-2.5-pro'); + expect(output).toContain('Usage limit reached'); + unmount(); + }); + + it('should display purchase prompt and credits update notice', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('purchase more AI Credits'); + expect(output).toContain( + 'Newly purchased AI credits may take a few minutes to update', + ); + unmount(); + }); + + it('should display reset time when provided', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('3:45 PM'); + expect(output).toContain('Access resets at'); + unmount(); + }); + + it('should not display reset time when not provided', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('Access resets at'); + unmount(); + }); + + it('should display slash command hints', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('/stats'); + expect(output).toContain('/model'); + expect(output).toContain('/auth'); + unmount(); + }); + }); + + describe('onChoice handling', () => { + it('should call onGetCredits and onChoice when get_credits is selected', async () => { + // get_credits is the first item, so just press Enter + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnGetCredits).toHaveBeenCalled(); + expect(mockOnChoice).toHaveBeenCalledWith('get_credits'); + }); + unmount(); + }); + + it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => { + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('get_credits'); + }); + unmount(); + }); + + it('should call onChoice with use_fallback when selected', async () => { + // With fallback: items are [get_credits, use_fallback, stop] + // use_fallback is the second item: Down + Enter + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\x1b[B'); // Down arrow + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('use_fallback'); + }); + unmount(); + }); + + it('should call onChoice with stop when selected', async () => { + // Without fallback: items are [get_credits, stop] + // stop is the second item: Down + Enter + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\x1b[B'); // Down arrow + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('stop'); + }); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.tsx new file mode 100644 index 0000000000..25d85829d3 --- /dev/null +++ b/packages/cli/src/ui/components/EmptyWalletDialog.tsx @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { theme } from '../semantic-colors.js'; + +/** Available choices in the empty wallet dialog */ +export type EmptyWalletChoice = 'get_credits' | 'use_fallback' | 'stop'; + +interface EmptyWalletDialogProps { + /** The model that hit the quota limit */ + failedModel: string; + /** The fallback model to offer (omit if none available) */ + fallbackModel?: string; + /** Time when access resets (human-readable) */ + resetTime?: string; + /** Callback to log click and open the browser for purchasing credits */ + onGetCredits?: () => void; + /** Callback when user makes a selection */ + onChoice: (choice: EmptyWalletChoice) => void; +} + +export function EmptyWalletDialog({ + failedModel, + fallbackModel, + resetTime, + onGetCredits, + onChoice, +}: EmptyWalletDialogProps): React.JSX.Element { + const items: Array<{ + label: string; + value: EmptyWalletChoice; + key: string; + }> = [ + { + label: 'Get AI Credits - Open browser to purchase credits', + value: 'get_credits', + key: 'get_credits', + }, + ]; + + if (fallbackModel) { + items.push({ + label: `Switch to ${fallbackModel}`, + value: 'use_fallback', + key: 'use_fallback', + }); + } + + items.push({ + label: 'Stop - Abort request', + value: 'stop', + key: 'stop', + }); + + const handleSelect = (choice: EmptyWalletChoice) => { + if (choice === 'get_credits') { + onGetCredits?.(); + } + onChoice(choice); + }; + + return ( + + + + Usage limit reached for {failedModel}. + + {resetTime && Access resets at {resetTime}.} + + + /stats + {' '} + model for usage details + + + + /model + {' '} + to switch models. + + + + /auth + {' '} + to switch to API key. + + + + To continue using this model now, purchase more AI Credits. + + + + Newly purchased AI credits may take a few minutes to update. + + + + How would you like to proceed? + + + + + + ); +} diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 26b61829a0..d691caba1a 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -11,6 +11,7 @@ import { waitFor } from '../../test-utils/async.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; +import { openFileInEditor } from '../utils/editorUtils.js'; import { ApprovalMode, validatePlanContent, @@ -19,6 +20,10 @@ import { } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; +vi.mock('../utils/editorUtils.js', () => ({ + openFileInEditor: vi.fn(), +})); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -144,6 +149,7 @@ Implement a comprehensive authentication system with multiple providers. onApprove={onApprove} onFeedback={onFeedback} onCancel={onCancel} + getPreferredEditor={vi.fn()} width={80} availableHeight={24} />, @@ -153,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers. getTargetDir: () => mockTargetDir, getIdeMode: () => false, isTrustedFolder: () => true, + getPreferredEditor: () => undefined, storage: { getPlansDir: () => mockPlansDir, }, @@ -160,6 +167,7 @@ Implement a comprehensive authentication system with multiple providers. readTextFile: vi.fn(), writeTextFile: vi.fn(), }), + getUseAlternateBuffer: () => options?.useAlternateBuffer ?? true, } as unknown as import('@google/gemini-cli-core').Config, }, ); @@ -418,6 +426,7 @@ Implement a comprehensive authentication system with multiple providers. onApprove={onApprove} onFeedback={onFeedback} onCancel={onCancel} + getPreferredEditor={vi.fn()} width={80} availableHeight={24} /> @@ -435,6 +444,7 @@ Implement a comprehensive authentication system with multiple providers. readTextFile: vi.fn(), writeTextFile: vi.fn(), }), + getUseAlternateBuffer: () => useAlternateBuffer ?? true, } as unknown as import('@google/gemini-cli-core').Config, }, ); @@ -535,6 +545,40 @@ Implement a comprehensive authentication system with multiple providers. }); expect(onFeedback).not.toHaveBeenCalled(); }); + + it('opens plan in external editor when Ctrl+X is pressed', async () => { + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + + await act(async () => { + vi.runAllTimers(); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('Add user authentication'); + }); + + // Reset the mock to track the second call during refresh + vi.mocked(processSingleFileContent).mockClear(); + + // Press Ctrl+X + await act(async () => { + writeKey(stdin, '\x18'); // Ctrl+X + }); + + await waitFor(() => { + expect(openFileInEditor).toHaveBeenCalledWith( + mockPlanFullPath, + expect.anything(), + expect.anything(), + undefined, + ); + }); + + // Verify that content is refreshed (processSingleFileContent called again) + await waitFor(() => { + expect(processSingleFileContent).toHaveBeenCalled(); + }); + }); }, ); }); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 8777136d86..6a5da1c299 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -5,25 +5,32 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; -import { Box, Text } from 'ink'; +import { useEffect, useState, useCallback } from 'react'; +import { Box, Text, useStdin } from 'ink'; import { ApprovalMode, validatePlanPath, validatePlanContent, QuestionType, type Config, + type EditorType, processSingleFileContent, + debugLogger, } from '@google/gemini-cli-core'; import { theme } from '../semantic-colors.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { AskUserDialog } from './AskUserDialog.js'; +import { openFileInEditor } from '../utils/editorUtils.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { formatCommand } from '../utils/keybindingUtils.js'; export interface ExitPlanModeDialogProps { planPath: string; onApprove: (approvalMode: ApprovalMode) => void; onFeedback: (feedback: string) => void; onCancel: () => void; + getPreferredEditor: () => EditorType | undefined; width: number; availableHeight?: number; } @@ -38,6 +45,7 @@ interface PlanContentState { status: PlanStatus; content?: string; error?: string; + refresh: () => void; } enum ApprovalOption { @@ -53,10 +61,15 @@ const StatusMessage: React.FC<{ }> = ({ children }) => {children}; function usePlanContent(planPath: string, config: Config): PlanContentState { - const [state, setState] = useState({ + const [version, setVersion] = useState(0); + const [state, setState] = useState>({ status: PlanStatus.Loading, }); + const refresh = useCallback(() => { + setVersion((v) => v + 1); + }, []); + useEffect(() => { let ignore = false; setState({ status: PlanStatus.Loading }); @@ -120,9 +133,9 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { return () => { ignore = true; }; - }, [planPath, config]); + }, [planPath, config, version]); - return state; + return { ...state, refresh }; } export const ExitPlanModeDialog: React.FC = ({ @@ -130,13 +143,36 @@ export const ExitPlanModeDialog: React.FC = ({ onApprove, onFeedback, onCancel, + getPreferredEditor, width, availableHeight, }) => { const config = useConfig(); + const { stdin, setRawMode } = useStdin(); const planState = usePlanContent(planPath, config); + const { refresh } = planState; const [showLoading, setShowLoading] = useState(false); + const handleOpenEditor = useCallback(async () => { + try { + await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor()); + refresh(); + } catch (err) { + debugLogger.error('Failed to open plan in editor:', err); + } + }, [planPath, stdin, setRawMode, getPreferredEditor, refresh]); + + useKeypress( + (key) => { + if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { + void handleOpenEditor(); + return true; + } + return false; + }, + { isActive: true, priority: true }, + ); + useEffect(() => { if (planState.status !== PlanStatus.Loading) { setShowLoading(false); @@ -183,6 +219,8 @@ export const ExitPlanModeDialog: React.FC = ({ ); } + const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR); + return ( = ({ onCancel={onCancel} width={width} availableHeight={availableHeight} + extraParts={[`${editHint} to edit plan`]} /> ); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 143e8319a3..9c253fec92 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; import { Footer } from './Footer.js'; -import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core'; +import { + makeFakeConfig, + tildeifyPath, + ToolCallDecision, +} from '@google/gemini-cli-core'; import type { SessionStatsState } from '../contexts/SessionContext.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -177,6 +181,8 @@ describe('