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/.gemini/skills/docs-changelog/SKILL.md b/.gemini/skills/docs-changelog/SKILL.md index d3a2f63623..f175260abd 100644 --- a/.gemini/skills/docs-changelog/SKILL.md +++ b/.gemini/skills/docs-changelog/SKILL.md @@ -59,6 +59,10 @@ To standardize the process of updating changelog files (`latest.md`, *Use this path if the version number ends in `.0`.* +**Important:** Based on the version, you must choose to follow either section +A.1 for stable releases or A.2 for preview releases. Do not follow the +instructions for the other section. + ### A.1: Stable Release (e.g., `v0.28.0`) For a stable release, you will generate two distinct summaries from the @@ -73,7 +77,8 @@ detailed **highlights** section for the release-specific page. use the existing announcements in `docs/changelogs/index.md` and the example within `.gemini/skills/docs-changelog/references/index_template.md` as your - guide. This format includes PR links and authors. + guide. This format includes PR links and authors. Stick to 1 or 2 PR + links and authors. - Add this new announcement to the top of `docs/changelogs/index.md`. 2. **Create Highlights and Update `latest.md`**: @@ -105,6 +110,10 @@ detailed **highlights** section for the release-specific page. *Use this path if the version number does **not** end in `.0`.* +**Important:** Based on the version, you must choose to follow either section +B.1 for stable patches or B.2 for preview patches. Do not follow the +instructions for the other section. + ### B.1: Stable Patch (e.g., `v0.28.1`) - **Target File**: `docs/changelogs/latest.md` @@ -113,10 +122,12 @@ detailed **highlights** section for the release-specific page. `# Latest stable release: {{version}}` 2. Update the rease date. The line should read, `Released: {{release_date_month_dd_yyyy}}` - 3. **Prepend** the processed "What's Changed" list from the temporary file + 3. Determine if a "What's Changed" section exists in the temporary file + If so, continue to step 4. Otherwise, skip to step 5. + 4. **Prepend** the processed "What's Changed" list from the temporary file to the existing "What's Changed" list in `latest.md`. Do not change or replace the existing list, **only add** to the beginning of it. - 4. In the "Full Changelog", edit **only** the end of the URL. Identify the + 5. In the "Full Changelog", edit **only** the end of the URL. Identify the last part of the URL that looks like `...{previous_version}` and update it to be `...{version}`. @@ -133,10 +144,12 @@ detailed **highlights** section for the release-specific page. `# Preview release: {{version}}` 2. Update the rease date. The line should read, `Released: {{release_date_month_dd_yyyy}}` - 3. **Prepend** the processed "What's Changed" list from the temporary file + 3. Determine if a "What's Changed" section exists in the temporary file + If so, continue to step 4. Otherwise, skip to step 5. + 4. **Prepend** the processed "What's Changed" list from the temporary file to the existing "What's Changed" list in `preview.md`. Do not change or replace the existing list, **only add** to the beginning of it. - 4. In the "Full Changelog", edit **only** the end of the URL. Identify the + 5. In the "Full Changelog", edit **only** the end of the URL. Identify the last part of the URL that looks like `...{previous_version}` and update it to be `...{version}`. @@ -149,5 +162,5 @@ detailed **highlights** section for the release-specific page. ## Finalize -- After making changes, run `npm run format` to ensure consistency. +- After making changes, run `npm run format` ONLY to ensure consistency. - Delete any temporary files created during the process. diff --git a/.gemini/skills/github-issue-creator/SKILL.md b/.gemini/skills/github-issue-creator/SKILL.md new file mode 100644 index 0000000000..53aa612607 --- /dev/null +++ b/.gemini/skills/github-issue-creator/SKILL.md @@ -0,0 +1,76 @@ +--- +name: github-issue-creator +description: + Use this skill when asked to create a GitHub issue. It handles different issue + types (bug, feature, etc.) using repository templates and ensures proper + labeling. +--- + +# GitHub Issue Creator + +This skill guides the creation of high-quality GitHub issues that adhere to the +repository's standards and use the appropriate templates. + +## Workflow + +Follow these steps to create a GitHub issue: + +1. **Identify Issue Type**: Determine if the request is a bug report, feature + request, or other category. + +2. **Locate Template**: Search for issue templates in + `.github/ISSUE_TEMPLATE/`. + - `bug_report.yml` + - `feature_request.yml` + - `website_issue.yml` + - If no relevant YAML template is found, look for `.md` templates in the same + directory. + +3. **Read Template**: Read the content of the identified template file to + understand the required fields. + +4. **Draft Content**: Draft the issue title and body/fields. + - If using a YAML template (form), prepare values for each `id` defined in + the template. + - If using a Markdown template, follow its structure exactly. + - **Default Label**: Always include the `🔒 maintainer only` label unless the + user explicitly requests otherwise. + +5. **Create Issue**: Use the `gh` CLI to create the issue. + - **CRITICAL:** To avoid shell escaping and formatting issues with + multi-line Markdown or complex text, ALWAYS write the description/body to + a temporary file first. + + **For Markdown Templates or Simple Body:** + ```bash + # 1. Write the drafted content to a temporary file + # 2. Create the issue using the --body-file flag + gh issue create --title "Succinct title" --body-file --label "🔒 maintainer only" + # 3. Remove the temporary file + rm + ``` + + **For YAML Templates (Forms):** + While `gh issue create` supports `--body-file`, YAML forms usually expect + key-value pairs via flags if you want to bypass the interactive prompt. + However, the most reliable non-interactive way to ensure formatting is + preserved for long text fields is to use the `--body` or `--body-file` if the + form has been converted to a standard body, OR to use the `--field` flags + for YAML forms. + + *Note: For the `gemini-cli` repository which uses YAML forms, you can often + submit the content as a single body if a specific field-based submission is + not required by the automation.* + +6. **Verify**: Confirm the issue was created successfully and provide the link + to the user. + +## Principles + +- **Clarity**: Titles should be descriptive and follow project conventions. +- **Defensive Formatting**: Always use temporary files with `--body-file` to + prevent newline and special character issues. +- **Maintainer Priority**: Default to internal/maintainer labels to keep the + backlog organized. +- **Completeness**: Provide all requested information (e.g., version info, + reproduction steps). diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8377d34af0..201d46a66d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,3 +14,4 @@ # Docs have a dedicated approver group in addition to maintainers /docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs +/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs \ No newline at end of file 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..3633c5027b 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,13 +283,14 @@ jobs: - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 with: ref: '${{ needs.parse_run_context.outputs.sha }}' repository: '${{ needs.parse_run_context.outputs.repository }}' + fetch-depth: 0 - name: 'Set up Node.js 20.x' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 @@ -301,7 +303,14 @@ jobs: - name: 'Build project' run: 'npm run build' + - name: 'Check if evals should run' + id: 'check_evals' + run: | + SHOULD_RUN=$(node scripts/changed_prompt.js) + echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" + - name: 'Run Evals (Required to pass)' + if: "${{ steps.check_evals.outputs.should_run == 'true' }}" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' run: 'npm run test:always_passing_evals' @@ -309,7 +318,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 +329,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..98635dbda7 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -27,6 +27,7 @@ jobs: deflake_e2e_linux: name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" strategy: fail-fast: false matrix: @@ -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,12 +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' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 @@ -167,4 +169,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/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml index 4198945159..366564d56e 100644 --- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml @@ -23,6 +23,10 @@ jobs: steps: - name: 'Generate GitHub App Token' id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} uses: 'actions/create-github-app-token@v2' with: app-id: '${{ secrets.APP_ID }}' @@ -33,7 +37,7 @@ jobs: env: DRY_RUN: '${{ inputs.dry_run }}' with: - github-token: '${{ steps.generate_token.outputs.token }}' + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' script: | const dryRun = process.env.DRY_RUN === 'true'; const thirtyDaysAgo = new Date(); diff --git a/.github/workflows/gemini-self-assign-issue.yml b/.github/workflows/gemini-self-assign-issue.yml index c0c79e5c04..454fc4f41b 100644 --- a/.github/workflows/gemini-self-assign-issue.yml +++ b/.github/workflows/gemini-self-assign-issue.yml @@ -25,7 +25,7 @@ jobs: if: |- github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issue_comment' && - contains(github.event.comment.body, '/assign') + (contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign')) runs-on: 'ubuntu-latest' steps: - name: 'Generate GitHub App Token' @@ -38,6 +38,7 @@ jobs: permission-issues: 'write' - name: 'Assign issue to user' + if: "contains(github.event.comment.body, '/assign')" uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token }}' @@ -108,3 +109,42 @@ jobs: issue_number: issueNumber, body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).` }); + + - name: 'Unassign issue from user' + if: "contains(github.event.comment.body, '/unassign')" + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const issueNumber = context.issue.number; + const commenter = context.actor; + const owner = context.repo.owner; + const repo = context.repo.repo; + const commentBody = context.payload.comment.body.trim(); + + if (commentBody !== '/unassign') { + return; + } + + const issue = await github.rest.issues.get({ + owner: owner, + repo: repo, + issue_number: issueNumber, + }); + + const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter); + + if (isAssigned) { + await github.rest.issues.removeAssignees({ + owner: owner, + repo: repo, + issue_number: issueNumber, + assignees: [commenter] + }); + await github.rest.issues.createComment({ + owner: owner, + repo: repo, + issue_number: issueNumber, + body: `👋 @${commenter}, you have been unassigned from this issue.` + }); + } diff --git a/.github/workflows/label-backlog-child-issues.yml b/.github/workflows/label-backlog-child-issues.yml index b11f509f80..a819bf4e71 100644 --- a/.github/workflows/label-backlog-child-issues.yml +++ b/.github/workflows/label-backlog-child-issues.yml @@ -14,7 +14,7 @@ permissions: jobs: # Event-based: Quick reaction to new/edited issues in THIS repo labeler: - if: "github.event_name == 'issues'" + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' @@ -36,7 +36,7 @@ jobs: # Scheduled/Manual: Recursive sync across multiple repos sync-maintainer-labels: - if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' diff --git a/.github/workflows/label-workstream-rollup.yml b/.github/workflows/label-workstream-rollup.yml index 35840cfe6f..97d699d09b 100644 --- a/.github/workflows/label-workstream-rollup.yml +++ b/.github/workflows/label-workstream-rollup.yml @@ -9,6 +9,7 @@ on: jobs: labeler: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' diff --git a/.github/workflows/release-change-tags.yml b/.github/workflows/release-change-tags.yml index 6184850677..c7c3f3f2d2 100644 --- a/.github/workflows/release-change-tags.yml +++ b/.github/workflows/release-change-tags.yml @@ -32,6 +32,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml index c9d2290a1c..f03bd52127 100644 --- a/.github/workflows/release-manual.yml +++ b/.github/workflows/release-manual.yml @@ -47,6 +47,7 @@ on: jobs: release: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 0a04e93517..8d453f7376 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -145,7 +145,7 @@ jobs: branch-name: 'release/${{ steps.nightly_version.outputs.RELEASE_TAG }}' pr-title: 'chore/release: bump version to ${{ steps.nightly_version.outputs.RELEASE_VERSION }}' pr-body: 'Automated version bump for nightly release.' - github-token: '${{ secrets.GITHUB_TOKEN }}' + github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ steps.vars.outputs.is_dry_run }}' working-directory: './release' diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 08a3625822..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' @@ -56,7 +57,18 @@ jobs: GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' BODY: '${{ github.event.inputs.body || github.event.release.body }}' + - name: 'Validate version' + id: 'validate_version' + run: | + if echo "${{ steps.release_info.outputs.VERSION }}" | grep -q "nightly"; then + echo "Nightly release detected. Stopping workflow." + echo "CONTINUE=false" >> "$GITHUB_OUTPUT" + else + echo "CONTINUE=true" >> "$GITHUB_OUTPUT" + fi + - name: 'Generate Changelog with Gemini' + if: "steps.validate_version.outputs.CONTINUE == 'true'" uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 with: gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' @@ -70,7 +82,10 @@ jobs: Execute the release notes generation process using the information provided. + When you are done, please output your thought process and the steps you took for future debugging purposes. + - name: 'Create Pull Request' + if: "steps.validate_version.outputs.CONTINUE == 'true'" uses: 'peter-evans/create-pull-request@v6' with: token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' 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..b822ce2f80 100644 --- a/.github/workflows/release-promote.yml +++ b/.github/workflows/release-promote.yml @@ -335,6 +335,7 @@ jobs: name: 'Create Nightly PR' needs: ['publish-stable', 'calculate-versions'] runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: contents: 'write' pull-requests: 'write' @@ -362,23 +363,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}" @@ -392,7 +398,7 @@ jobs: branch-name: '${{ steps.release_branch.outputs.BRANCH_NAME }}' pr-title: 'chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' pr-body: 'Automated version bump to prepare for the next nightly release.' - github-token: '${{ secrets.GITHUB_TOKEN }}' + github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' dry-run: '${{ github.event.inputs.dry_run }}' - name: 'Create Issue on Failure' diff --git a/.github/workflows/release-rollback.yml b/.github/workflows/release-rollback.yml index 75c2d0c799..db91457b1a 100644 --- a/.github/workflows/release-rollback.yml +++ b/.github/workflows/release-rollback.yml @@ -42,6 +42,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" runs-on: 'ubuntu-latest' permissions: @@ -203,7 +204,7 @@ jobs: run: | ROLLBACK_COMMIT=$(git rev-parse -q --verify "$TARGET_TAG") if [ "$ROLLBACK_COMMIT" != "$TARGET_HASH" ]; then - echo '❌ Failed to add tag $TARGET_TAG to commit $TARGET_HASH' + echo "❌ Failed to add tag ${TARGET_TAG} to commit ${TARGET_HASH}" echo '❌ This means the tag was not added, and the workflow should fail.' exit 1 fi diff --git a/.github/workflows/release-sandbox.yml b/.github/workflows/release-sandbox.yml index f1deb0380c..2c7de7a0f5 100644 --- a/.github/workflows/release-sandbox.yml +++ b/.github/workflows/release-sandbox.yml @@ -16,6 +16,7 @@ on: jobs: build: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'read' diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index caeb0bebe0..29903dfbe8 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -20,6 +20,7 @@ on: jobs: smoke-test: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' diff --git a/.github/workflows/test-build-binary.yml b/.github/workflows/test-build-binary.yml new file mode 100644 index 0000000000..f11181a9f0 --- /dev/null +++ b/.github/workflows/test-build-binary.yml @@ -0,0 +1,160 @@ +name: 'Test Build Binary' + +on: + workflow_dispatch: + +permissions: + contents: 'read' + +defaults: + run: + shell: 'bash' + +jobs: + build-node-binary: + name: 'Build Binary (${{ matrix.os }})' + runs-on: '${{ matrix.os }}' + strategy: + fail-fast: false + matrix: + include: + - os: 'ubuntu-latest' + platform_name: 'linux-x64' + arch: 'x64' + - os: 'windows-latest' + platform_name: 'win32-x64' + arch: 'x64' + - os: 'macos-latest' # Apple Silicon (ARM64) + platform_name: 'darwin-arm64' + arch: 'arm64' + - os: 'macos-latest' # Intel (x64) running on ARM via Rosetta + platform_name: 'darwin-x64' + arch: 'x64' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@v4' + + - name: 'Optimize Windows Performance' + if: "matrix.os == 'windows-latest'" + run: | + Set-MpPreference -DisableRealtimeMonitoring $true + Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue + Set-Service -Name "wsearch" -StartupType Disabled + Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue + Set-Service -Name "SysMain" -StartupType Disabled + shell: 'powershell' + + - name: 'Set up Node.js' + uses: 'actions/setup-node@v4' + with: + node-version-file: '.nvmrc' + architecture: '${{ matrix.arch }}' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Check Secrets' + id: 'check_secrets' + run: | + echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT" + echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT" + + - name: 'Setup Windows SDK (Windows)' + if: "matrix.os == 'windows-latest'" + uses: 'microsoft/setup-msbuild@v2' + + - name: 'Add Signtool to Path (Windows)' + if: "matrix.os == 'windows-latest'" + run: | + $signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName + echo "Found signtool at: $signtoolPath" + echo "$signtoolPath" >> $env:GITHUB_PATH + shell: 'pwsh' + + - name: 'Setup macOS Keychain' + if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'" + env: + BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}' + P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}' + KEYCHAIN_PASSWORD: 'temp-password' + run: | + # Create the P12 file + echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12 + + # Create a temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + + # Import the certificate + security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign + + # Allow codesign to access it + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain + + # Set Identity for build script + echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV" + + - name: 'Setup Windows Certificate' + if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'" + env: + PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}' + PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}' + run: | + $pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64") + $certPath = Join-Path (Get-Location) "cert.pfx" + [IO.File]::WriteAllBytes($certPath, $pfx_cert_byte) + echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV + echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV + shell: 'pwsh' + + - name: 'Build Binary' + run: 'npm run build:binary' + + - name: 'Build Core Package' + run: 'npm run build -w @google/gemini-cli-core' + + - name: 'Verify Output Exists' + run: | + if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then + echo "Binary found at dist/${{ matrix.platform_name }}/gemini" + elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then + echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe" + else + echo "Error: Binary not found in dist/${{ matrix.platform_name }}/" + ls -R dist/ + exit 1 + fi + + - name: 'Smoke Test Binary' + run: | + echo "Running binary smoke test..." + if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then + "./dist/${{ matrix.platform_name }}/gemini.exe" --version + else + "./dist/${{ matrix.platform_name }}/gemini" --version + fi + + - name: 'Run Integration Tests' + if: "github.event_name != 'pull_request'" + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + run: | + echo "Running integration tests with binary..." + if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then + BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")" + else + BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini" + fi + echo "Using binary at $BINARY_PATH" + export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH" + npm run test:integration:sandbox:none -- --testTimeout=600000 + + - name: 'Upload Artifact' + uses: 'actions/upload-artifact@v4' + with: + name: 'gemini-cli-${{ matrix.platform_name }}' + path: 'dist/${{ matrix.platform_name }}/' + retention-days: 5 diff --git a/.github/workflows/trigger_e2e.yml b/.github/workflows/trigger_e2e.yml index 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/.gitignore b/.gitignore index 0438549485..a2a6553cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,4 @@ gemini-debug.log .genkit .gemini-clipboard/ .eslintcache -evals/logs/ \ No newline at end of file +evals/logs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28e3c775d3..d442f408f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,11 +75,14 @@ Replace `` with your pull request number. Authors are encouraged to run this on their own PRs for self-review, and reviewers should use it to augment their manual review process. -### Self assigning issues +### Self-assigning and unassigning issues -To assign an issue to yourself, simply add a comment with the text `/assign`. -The comment must contain only that text and nothing else. This command will -assign the issue to you, provided it is not already assigned. +To assign an issue to yourself, simply add a comment with the text `/assign`. To +unassign yourself from an issue, add a comment with the text `/unassign`. + +The comment must contain only that text and nothing else. These commands will +assign or unassign the issue as requested, provided the conditions are met +(e.g., an issue must be unassigned to be assigned). Please note that you can have a maximum of 3 issues assigned to you at any given time. diff --git a/README.md b/README.md index f44a2e238d..02dd4988f0 100644 --- a/README.md +++ b/README.md @@ -282,14 +282,14 @@ gemini quickly. - [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed auth configuration. -- [**Configuration Guide**](./docs/get-started/configuration.md) - Settings and +- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and customization. -- [**Keyboard Shortcuts**](./docs/cli/keyboard-shortcuts.md) - Productivity - tips. +- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) - + Productivity tips. ### Core Features -- [**Commands Reference**](./docs/cli/commands.md) - All slash commands +- [**Commands Reference**](./docs/reference/commands.md) - All slash commands (`/help`, `/chat`, etc). - [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own reusable commands. @@ -323,15 +323,16 @@ gemini - [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a corporate environment. - [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking. -- [**Tools API Development**](./docs/core/tools-api.md) - Create custom tools. +- [**Tools API Development**](./docs/reference/tools-api.md) - Create custom + tools. - [**Local development**](./docs/local-development.md) - Local development tooling. ### Troubleshooting & Support -- [**Troubleshooting Guide**](./docs/troubleshooting.md) - Common issues and - solutions. -- [**FAQ**](./docs/faq.md) - Frequently asked questions. +- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common + issues and solutions. +- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions. - Use `/bug` command to report issues directly from the CLI. ### Using MCP Servers @@ -377,7 +378,8 @@ for planned features and priorities. ### Uninstall -See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions. +See the [Uninstall Guide](./docs/resources/uninstall.md) for removal +instructions. ## 📄 Legal diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 3cff4c123b..537e9d1aee 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,49 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.31.0 - 2026-02-27 + +- **Gemini 3.1 Pro Preview:** Gemini CLI now supports the new Gemini 3.1 Pro + Preview model + ([#19676](https://github.com/google-gemini/gemini-cli/pull/19676) by + @sehoon38). +- **Experimental Browser Agent:** We've introduced a new experimental browser + agent to interact with web pages + ([#19284](https://github.com/google-gemini/gemini-cli/pull/19284) by + @gsquared94). +- **Policy Engine Updates:** The policy engine now supports project-level + policies, MCP server wildcards, and tool annotation matching + ([#18682](https://github.com/google-gemini/gemini-cli/pull/18682) by + @Abhijit-2592, + [#20024](https://github.com/google-gemini/gemini-cli/pull/20024) by @jerop). +- **Web Fetch Improvements:** We've implemented an experimental direct web fetch + feature and added rate limiting to mitigate DDoS risks + ([#19557](https://github.com/google-gemini/gemini-cli/pull/19557) by @mbleigh, + [#19567](https://github.com/google-gemini/gemini-cli/pull/19567) by + @mattKorwel). + +## Announcements: v0.30.0 - 2026-02-25 + +- **SDK & Custom Skills:** Introduced the initial SDK package, enabling dynamic + system instructions, `SessionContext` for SDK tool calls, and support for + custom skills + ([#18861](https://github.com/google-gemini/gemini-cli/pull/18861) by + @mbleigh). +- **Policy Engine Enhancements:** Added a new `--policy` flag for user-defined + policies, introduced strict seatbelt profiles, and deprecated + `--allowed-tools` in favor of the policy engine + ([#18500](https://github.com/google-gemini/gemini-cli/pull/18500) by + @allenhutchison). +- **UI & Themes:** Added a generic searchable list for settings and extensions, + new Solarized themes, text wrapping for markdown tables, and a clean UI toggle + prototype ([#19064](https://github.com/google-gemini/gemini-cli/pull/19064) by + @rmedranollamas). +- **Vim & Terminal Interaction:** Improved Vim support to feel more complete and + added support for Ctrl-Z terminal suspension + ([#18755](https://github.com/google-gemini/gemini-cli/pull/18755) by + @ppgranger, [#18931](https://github.com/google-gemini/gemini-cli/pull/18931) + by @scidomino). + ## Announcements: v0.29.0 - 2026-02-17 - **Plan Mode:** A new comprehensive planning capability with `/plan`, @@ -421,8 +464,9 @@ on GitHub. page in their default browser directly from the CLI using the `/extension` explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846) by [@JayadityaGit](https://github.com/JayadityaGit)). -- **Configurable compression:** Users can modify the compression threshold in - `/settings`. The default has been made more proactive +- **Configurable compression:** Users can modify the context compression + threshold in `/settings` (decimal with percentage display). The default has + been made more proactive ([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by [@scidomino](https://github.com/scidomino)). - **API key authentication:** Users can now securely enter and store their diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 91d669ba77..760e070bd9 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.29.0 +# Latest stable release: v0.31.0 -Released: February 17, 2026 +Released: February 27, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,371 +11,405 @@ npm install -g @google/gemini-cli ## Highlights -- **Plan Mode:** Introduce a dedicated "Plan Mode" to help you architect complex - changes before implementation. Use `/plan` to get started. -- **Gemini 3 by Default:** Gemini 3 is now the default model family, bringing - improved performance and reasoning capabilities to all users without needing a - feature flag. -- **Extension Discovery:** Easily discover and install extensions with the new - exploration features and registry client. -- **Enhanced Admin Controls:** New administrative capabilities allow for - allowlisting MCP server configurations, giving organizations more control over - available tools. -- **Sub-agent Improvements:** Sub-agents have been transitioned to a new format - with improved definitions and system prompts for better reliability. +- **Gemini 3.1 Pro Preview:** Gemini CLI now supports the new Gemini 3.1 Pro + Preview model. +- **Experimental Browser Agent:** We've introduced a new experimental browser + agent to directly interact with web pages and retrieve context. +- **Policy Engine Updates:** The policy engine has been expanded to support + project-level policies, MCP server wildcards, and tool annotation matching, + providing greater control over tool executions. +- **Web Fetch Enhancements:** A new experimental direct web fetch tool has been + implemented, alongside rate-limiting features for enhanced security. +- **Improved Plan Mode:** Plan Mode now includes support for custom storage + directories, automatic model switching, and summarizing work after execution. ## What's Changed -- fix: remove `ask_user` tool from non-interactive modes by @jackwotherspoon in - [#18154](https://github.com/google-gemini/gemini-cli/pull/18154) -- fix(cli): allow restricted .env loading in untrusted sandboxed folders by - @galz10 in [#17806](https://github.com/google-gemini/gemini-cli/pull/17806) -- Encourage agent to utilize ecosystem tools to perform work by @gundermanc in - [#17881](https://github.com/google-gemini/gemini-cli/pull/17881) -- feat(plan): unify workflow location in system prompt to optimize caching by - @jerop in [#18258](https://github.com/google-gemini/gemini-cli/pull/18258) -- feat(core): enable getUserTierName in config by @sehoon38 in - [#18265](https://github.com/google-gemini/gemini-cli/pull/18265) -- feat(core): add default execution limits for subagents by @abhipatel12 in - [#18274](https://github.com/google-gemini/gemini-cli/pull/18274) -- Fix issue where agent gets stuck at interactive commands. by @gundermanc in - [#18272](https://github.com/google-gemini/gemini-cli/pull/18272) -- chore(release): bump version to 0.29.0-nightly.20260203.71f46f116 by - @gemini-cli-robot in - [#18243](https://github.com/google-gemini/gemini-cli/pull/18243) -- feat(core): remove hardcoded policy bypass for local subagents by @abhipatel12 - in [#18153](https://github.com/google-gemini/gemini-cli/pull/18153) -- feat(plan): implement `plan` slash command by @Adib234 in - [#17698](https://github.com/google-gemini/gemini-cli/pull/17698) -- feat: increase `ask_user` label limit to 16 characters by @jackwotherspoon in - [#18320](https://github.com/google-gemini/gemini-cli/pull/18320) -- Add information about the agent skills lifecycle and clarify docs-writer skill - metadata. by @g-samroberts in - [#18234](https://github.com/google-gemini/gemini-cli/pull/18234) -- feat(core): add `enter_plan_mode` tool by @jerop in - [#18324](https://github.com/google-gemini/gemini-cli/pull/18324) -- Stop showing an error message in `/plan` by @Adib234 in - [#18333](https://github.com/google-gemini/gemini-cli/pull/18333) -- fix(hooks): remove unnecessary logging for hook registration by @abhipatel12 - in [#18332](https://github.com/google-gemini/gemini-cli/pull/18332) -- fix(mcp): ensure MCP transport is closed to prevent memory leaks by - @cbcoutinho in - [#18054](https://github.com/google-gemini/gemini-cli/pull/18054) -- feat(skills): implement linking for agent skills by @MushuEE in - [#18295](https://github.com/google-gemini/gemini-cli/pull/18295) -- Changelogs for 0.27.0 and 0.28.0-preview0 by @g-samroberts in - [#18336](https://github.com/google-gemini/gemini-cli/pull/18336) -- chore: correct docs as skills and hooks are stable by @jackwotherspoon in - [#18358](https://github.com/google-gemini/gemini-cli/pull/18358) -- feat(admin): Implement admin allowlist for MCP server configurations by - @skeshive in [#18311](https://github.com/google-gemini/gemini-cli/pull/18311) -- fix(core): add retry logic for transient SSL/TLS errors (#17318) by @ppgranger - in [#18310](https://github.com/google-gemini/gemini-cli/pull/18310) -- Add support for /extensions config command by @chrstnb in - [#17895](https://github.com/google-gemini/gemini-cli/pull/17895) -- fix(core): handle non-compliant mcpbridge responses from Xcode 26.3 by - @peterfriese in - [#18376](https://github.com/google-gemini/gemini-cli/pull/18376) -- feat(cli): Add W, B, E Vim motions and operator support by @ademuri in - [#16209](https://github.com/google-gemini/gemini-cli/pull/16209) -- fix: Windows Specific Agent Quality & System Prompt by @scidomino in - [#18351](https://github.com/google-gemini/gemini-cli/pull/18351) -- feat(plan): support `replace` tool in plan mode to edit plans by @jerop in - [#18379](https://github.com/google-gemini/gemini-cli/pull/18379) -- Improving memory tool instructions and eval testing by @alisa-alisa in - [#18091](https://github.com/google-gemini/gemini-cli/pull/18091) -- fix(cli): color extension link success message green by @MushuEE in - [#18386](https://github.com/google-gemini/gemini-cli/pull/18386) -- undo by @jacob314 in - [#18147](https://github.com/google-gemini/gemini-cli/pull/18147) -- feat(plan): add guidance on iterating on approved plans vs creating new plans - by @jerop in [#18346](https://github.com/google-gemini/gemini-cli/pull/18346) -- feat(plan): fix invalid tool calls in plan mode by @Adib234 in - [#18352](https://github.com/google-gemini/gemini-cli/pull/18352) -- feat(plan): integrate planning artifacts and tools into primary workflows by - @jerop in [#18375](https://github.com/google-gemini/gemini-cli/pull/18375) -- Fix permission check by @scidomino in - [#18395](https://github.com/google-gemini/gemini-cli/pull/18395) -- ux(polish) autocomplete in the input prompt by @jacob314 in - [#18181](https://github.com/google-gemini/gemini-cli/pull/18181) -- fix: resolve infinite loop when using 'Modify with external editor' by - @ppgranger in [#17453](https://github.com/google-gemini/gemini-cli/pull/17453) -- feat: expand verify-release to macOS and Windows by @yunaseoul in - [#18145](https://github.com/google-gemini/gemini-cli/pull/18145) -- feat(plan): implement support for MCP servers in Plan mode by @Adib234 in - [#18229](https://github.com/google-gemini/gemini-cli/pull/18229) -- chore: update folder trust error messaging by @galz10 in - [#18402](https://github.com/google-gemini/gemini-cli/pull/18402) -- feat(plan): create a metric for execution of plans generated in plan mode by - @Adib234 in [#18236](https://github.com/google-gemini/gemini-cli/pull/18236) -- perf(ui): optimize stripUnsafeCharacters with regex by @gsquared94 in - [#18413](https://github.com/google-gemini/gemini-cli/pull/18413) -- feat(context): implement observation masking for tool outputs by @abhipatel12 - in [#18389](https://github.com/google-gemini/gemini-cli/pull/18389) -- feat(core,cli): implement session-linked tool output storage and cleanup by - @abhipatel12 in - [#18416](https://github.com/google-gemini/gemini-cli/pull/18416) -- Shorten temp directory by @joshualitt in - [#17901](https://github.com/google-gemini/gemini-cli/pull/17901) -- feat(plan): add behavioral evals for plan mode by @jerop in - [#18437](https://github.com/google-gemini/gemini-cli/pull/18437) -- Add extension registry client by @chrstnb in - [#18396](https://github.com/google-gemini/gemini-cli/pull/18396) -- Enable extension config by default by @chrstnb in - [#18447](https://github.com/google-gemini/gemini-cli/pull/18447) -- Automatically generate change logs on release by @g-samroberts in - [#18401](https://github.com/google-gemini/gemini-cli/pull/18401) -- Remove previewFeatures and default to Gemini 3 by @sehoon38 in - [#18414](https://github.com/google-gemini/gemini-cli/pull/18414) -- feat(admin): apply MCP allowlist to extensions & gemini mcp list command by - @skeshive in [#18442](https://github.com/google-gemini/gemini-cli/pull/18442) -- fix(cli): improve focus navigation for interactive and background shells by - @galz10 in [#18343](https://github.com/google-gemini/gemini-cli/pull/18343) -- Add shortcuts hint and panel for discoverability by @LyalinDotCom in - [#18035](https://github.com/google-gemini/gemini-cli/pull/18035) -- fix(config): treat system settings as read-only during migration and warn user - by @spencer426 in - [#18277](https://github.com/google-gemini/gemini-cli/pull/18277) -- feat(plan): add positive test case and update eval stability policy by @jerop - in [#18457](https://github.com/google-gemini/gemini-cli/pull/18457) -- fix- windows: add shell: true for spawnSync to fix EINVAL with .cmd editors by - @zackoch in [#18408](https://github.com/google-gemini/gemini-cli/pull/18408) -- bug(core): Fix bug when saving plans. by @joshualitt in - [#18465](https://github.com/google-gemini/gemini-cli/pull/18465) -- Refactor atCommandProcessor by @scidomino in - [#18461](https://github.com/google-gemini/gemini-cli/pull/18461) -- feat(core): implement persistence and resumption for masked tool outputs by - @abhipatel12 in - [#18451](https://github.com/google-gemini/gemini-cli/pull/18451) -- refactor: simplify tool output truncation to single config by @SandyTao520 in - [#18446](https://github.com/google-gemini/gemini-cli/pull/18446) -- bug(core): Ensure storage is initialized early, even if config is not. by - @joshualitt in - [#18471](https://github.com/google-gemini/gemini-cli/pull/18471) -- chore: Update build-and-start script to support argument forwarding by +- Use ranged reads and limited searches and fuzzy editing improvements by + @gundermanc in + [#19240](https://github.com/google-gemini/gemini-cli/pull/19240) +- Fix bottom border color by @jacob314 in + [#19266](https://github.com/google-gemini/gemini-cli/pull/19266) +- Release note generator fix by @g-samroberts in + [#19363](https://github.com/google-gemini/gemini-cli/pull/19363) +- test(evals): add behavioral tests for tool output masking by @NTaylorMullen in + [#19172](https://github.com/google-gemini/gemini-cli/pull/19172) +- docs: clarify preflight instructions in GEMINI.md by @NTaylorMullen in + [#19377](https://github.com/google-gemini/gemini-cli/pull/19377) +- feat(cli): add gemini --resume hint on exit by @Mag1ck in + [#16285](https://github.com/google-gemini/gemini-cli/pull/16285) +- fix: optimize height calculations for ask_user dialog by @jackwotherspoon in + [#19017](https://github.com/google-gemini/gemini-cli/pull/19017) +- feat(cli): add Alt+D for forward word deletion by @scidomino in + [#19300](https://github.com/google-gemini/gemini-cli/pull/19300) +- Disable failing eval test by @chrstnb in + [#19455](https://github.com/google-gemini/gemini-cli/pull/19455) +- fix(cli): support legacy onConfirm callback in ToolActionsContext by + @SandyTao520 in + [#19369](https://github.com/google-gemini/gemini-cli/pull/19369) +- chore(deps): bump tar from 7.5.7 to 7.5.8 by @.github/dependabot.yml[bot] in + [#19367](https://github.com/google-gemini/gemini-cli/pull/19367) +- fix(plan): allow safe fallback when experiment setting for plan is not enabled + but approval mode at startup is plan by @Adib234 in + [#19439](https://github.com/google-gemini/gemini-cli/pull/19439) +- Add explicit color-convert dependency by @chrstnb in + [#19460](https://github.com/google-gemini/gemini-cli/pull/19460) +- feat(devtools): migrate devtools package into monorepo by @SandyTao520 in + [#18936](https://github.com/google-gemini/gemini-cli/pull/18936) +- fix(core): clarify plan mode constraints and exit mechanism by @jerop in + [#19438](https://github.com/google-gemini/gemini-cli/pull/19438) +- feat(cli): add macOS run-event notifications (interactive only) by + @LyalinDotCom in + [#19056](https://github.com/google-gemini/gemini-cli/pull/19056) +- Changelog for v0.29.0 by @gemini-cli-robot in + [#19361](https://github.com/google-gemini/gemini-cli/pull/19361) +- fix(ui): preventing empty history items from being added by @devr0306 in + [#19014](https://github.com/google-gemini/gemini-cli/pull/19014) +- Changelog for v0.30.0-preview.0 by @gemini-cli-robot in + [#19364](https://github.com/google-gemini/gemini-cli/pull/19364) +- feat(core): add support for MCP progress updates by @NTaylorMullen in + [#19046](https://github.com/google-gemini/gemini-cli/pull/19046) +- fix(core): ensure directory exists before writing conversation file by + @godwiniheuwa in + [#18429](https://github.com/google-gemini/gemini-cli/pull/18429) +- fix(ui): move margin from top to bottom in ToolGroupMessage by @imadraude in + [#17198](https://github.com/google-gemini/gemini-cli/pull/17198) +- fix(cli): treat unknown slash commands as regular input instead of showing + error by @skyvanguard in + [#17393](https://github.com/google-gemini/gemini-cli/pull/17393) +- feat(core): experimental in-progress steering hints (2 of 2) by @joshualitt in + [#19307](https://github.com/google-gemini/gemini-cli/pull/19307) +- docs(plan): add documentation for plan mode command by @Adib234 in + [#19467](https://github.com/google-gemini/gemini-cli/pull/19467) +- fix(core): ripgrep fails when pattern looks like ripgrep flag by @syvb in + [#18858](https://github.com/google-gemini/gemini-cli/pull/18858) +- fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling by + @NTaylorMullen in + [#19451](https://github.com/google-gemini/gemini-cli/pull/19451) +- use issuer instead of authorization_endpoint for oauth discovery by + @garrettsparks in + [#17332](https://github.com/google-gemini/gemini-cli/pull/17332) +- feat(cli): include `/dir add` directories in @ autocomplete suggestions by + @jasmeetsb in [#19246](https://github.com/google-gemini/gemini-cli/pull/19246) +- feat(admin): Admin settings should only apply if adminControlsApplicable = + true and fetch errors should be fatal by @skeshive in + [#19453](https://github.com/google-gemini/gemini-cli/pull/19453) +- Format strict-development-rules command by @g-samroberts in + [#19484](https://github.com/google-gemini/gemini-cli/pull/19484) +- feat(core): centralize compatibility checks and add TrueColor detection by + @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 - [#18241](https://github.com/google-gemini/gemini-cli/pull/18241) -- fix(core): prevent subagent bypass in plan mode by @jerop in - [#18484](https://github.com/google-gemini/gemini-cli/pull/18484) -- feat(cli): add WebSocket-based network logging and streaming chunk support by + [#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 - [#18383](https://github.com/google-gemini/gemini-cli/pull/18383) -- feat(cli): update approval modes UI by @jerop in - [#18476](https://github.com/google-gemini/gemini-cli/pull/18476) -- fix(cli): reload skills and agents on extension restart by @NTaylorMullen in - [#18411](https://github.com/google-gemini/gemini-cli/pull/18411) -- fix(core): expand excludeTools with legacy aliases for renamed tools by - @SandyTao520 in - [#18498](https://github.com/google-gemini/gemini-cli/pull/18498) -- feat(core): overhaul system prompt for rigor, integrity, and intent alignment - by @NTaylorMullen in - [#17263](https://github.com/google-gemini/gemini-cli/pull/17263) -- Patch for generate changelog docs yaml file by @g-samroberts in - [#18496](https://github.com/google-gemini/gemini-cli/pull/18496) -- Code review fixes for show question mark pr. by @jacob314 in - [#18480](https://github.com/google-gemini/gemini-cli/pull/18480) -- fix(cli): add SS3 Shift+Tab support for Windows terminals by @ThanhNguyxn in - [#18187](https://github.com/google-gemini/gemini-cli/pull/18187) -- chore: remove redundant planning prompt from final shell by @jerop in - [#18528](https://github.com/google-gemini/gemini-cli/pull/18528) -- docs: require pr-creator skill for PR generation by @NTaylorMullen in - [#18536](https://github.com/google-gemini/gemini-cli/pull/18536) -- chore: update colors for ask_user dialog by @jackwotherspoon in - [#18543](https://github.com/google-gemini/gemini-cli/pull/18543) -- feat(core): exempt high-signal tools from output masking by @abhipatel12 in - [#18545](https://github.com/google-gemini/gemini-cli/pull/18545) -- refactor(core): remove memory tool instructions from Gemini 3 prompt by - @NTaylorMullen in - [#18559](https://github.com/google-gemini/gemini-cli/pull/18559) -- chore: remove feedback instruction from system prompt by @NTaylorMullen in - [#18560](https://github.com/google-gemini/gemini-cli/pull/18560) -- feat(context): add remote configuration for tool output masking thresholds by - @abhipatel12 in - [#18553](https://github.com/google-gemini/gemini-cli/pull/18553) -- feat(core): pause agent timeout budget while waiting for tool confirmation by - @abhipatel12 in - [#18415](https://github.com/google-gemini/gemini-cli/pull/18415) -- refactor(config): remove experimental.enableEventDrivenScheduler setting by - @abhipatel12 in - [#17924](https://github.com/google-gemini/gemini-cli/pull/17924) -- feat(cli): truncate shell output in UI history and improve active shell - display by @jwhelangoog in - [#17438](https://github.com/google-gemini/gemini-cli/pull/17438) -- refactor(cli): switch useToolScheduler to event-driven engine by @abhipatel12 - in [#18565](https://github.com/google-gemini/gemini-cli/pull/18565) -- fix(core): correct escaped interpolation in system prompt by @NTaylorMullen in - [#18557](https://github.com/google-gemini/gemini-cli/pull/18557) -- propagate abortSignal by @scidomino in - [#18477](https://github.com/google-gemini/gemini-cli/pull/18477) -- feat(core): conditionally include ctrl+f prompt based on interactive shell - setting by @NTaylorMullen in - [#18561](https://github.com/google-gemini/gemini-cli/pull/18561) -- fix(core): ensure `enter_plan_mode` tool registration respects - `experimental.plan` by @jerop in - [#18587](https://github.com/google-gemini/gemini-cli/pull/18587) -- feat(core): transition sub-agents to XML format and improve definitions by - @NTaylorMullen in - [#18555](https://github.com/google-gemini/gemini-cli/pull/18555) -- docs: Add Plan Mode documentation by @jerop in - [#18582](https://github.com/google-gemini/gemini-cli/pull/18582) -- chore: strengthen validation guidance in system prompt by @NTaylorMullen in - [#18544](https://github.com/google-gemini/gemini-cli/pull/18544) -- Fix newline insertion bug in replace tool by @werdnum in - [#18595](https://github.com/google-gemini/gemini-cli/pull/18595) -- fix(evals): update save_memory evals and simplify tool description by - @NTaylorMullen in - [#18610](https://github.com/google-gemini/gemini-cli/pull/18610) -- chore(evals): update validation_fidelity_pre_existing_errors to USUALLY_PASSES - by @NTaylorMullen in - [#18617](https://github.com/google-gemini/gemini-cli/pull/18617) -- fix: shorten tool call IDs and fix duplicate tool name in truncated output - filenames by @SandyTao520 in - [#18600](https://github.com/google-gemini/gemini-cli/pull/18600) -- feat(cli): implement atomic writes and safety checks for trusted folders by - @galz10 in [#18406](https://github.com/google-gemini/gemini-cli/pull/18406) -- Remove relative docs links by @chrstnb in - [#18650](https://github.com/google-gemini/gemini-cli/pull/18650) -- docs: add legacy snippets convention to GEMINI.md by @NTaylorMullen in - [#18597](https://github.com/google-gemini/gemini-cli/pull/18597) -- fix(chore): Support linting for cjs by @aswinashok44 in - [#18639](https://github.com/google-gemini/gemini-cli/pull/18639) -- feat: move shell efficiency guidelines to tool description by @NTaylorMullen - in [#18614](https://github.com/google-gemini/gemini-cli/pull/18614) -- Added "" as default value, since getText() used to expect a string only and - thus crashed when undefined... Fixes #18076 by @019-Abhi in - [#18099](https://github.com/google-gemini/gemini-cli/pull/18099) -- Allow @-includes outside of workspaces (with permission) by @scidomino in - [#18470](https://github.com/google-gemini/gemini-cli/pull/18470) -- chore: make `ask_user` header description more clear by @jackwotherspoon in - [#18657](https://github.com/google-gemini/gemini-cli/pull/18657) -- refactor(core): model-dependent tool definitions by @aishaneeshah in - [#18563](https://github.com/google-gemini/gemini-cli/pull/18563) -- Harded code assist converter. by @jacob314 in - [#18656](https://github.com/google-gemini/gemini-cli/pull/18656) -- bug(core): Fix minor bug in migration logic. by @joshualitt in - [#18661](https://github.com/google-gemini/gemini-cli/pull/18661) -- feat: enable plan mode experiment in settings by @jerop in - [#18636](https://github.com/google-gemini/gemini-cli/pull/18636) -- refactor: push isValidPath() into parsePastedPaths() by @scidomino in - [#18664](https://github.com/google-gemini/gemini-cli/pull/18664) -- fix(cli): correct 'esc to cancel' position and restore duration display by - @NTaylorMullen in - [#18534](https://github.com/google-gemini/gemini-cli/pull/18534) -- feat(cli): add DevTools integration with gemini-cli-devtools by @SandyTao520 - in [#18648](https://github.com/google-gemini/gemini-cli/pull/18648) -- chore: remove unused exports and redundant hook files by @SandyTao520 in - [#18681](https://github.com/google-gemini/gemini-cli/pull/18681) -- Fix number of lines being reported in rewind confirmation dialog by @Adib234 - in [#18675](https://github.com/google-gemini/gemini-cli/pull/18675) -- feat(cli): disable folder trust in headless mode by @galz10 in - [#18407](https://github.com/google-gemini/gemini-cli/pull/18407) -- Disallow unsafe type assertions by @gundermanc in - [#18688](https://github.com/google-gemini/gemini-cli/pull/18688) -- Change event type for release by @g-samroberts in - [#18693](https://github.com/google-gemini/gemini-cli/pull/18693) -- feat: handle multiple dynamic context filenames in system prompt by - @NTaylorMullen in - [#18598](https://github.com/google-gemini/gemini-cli/pull/18598) -- Properly parse at-commands with narrow non-breaking spaces by @scidomino in - [#18677](https://github.com/google-gemini/gemini-cli/pull/18677) -- refactor(core): centralize core tool definitions and support model-specific - schemas by @aishaneeshah in - [#18662](https://github.com/google-gemini/gemini-cli/pull/18662) -- feat(core): Render memory hierarchically in context. by @joshualitt in - [#18350](https://github.com/google-gemini/gemini-cli/pull/18350) -- feat: Ctrl+O to expand paste placeholder by @jackwotherspoon in - [#18103](https://github.com/google-gemini/gemini-cli/pull/18103) -- fix(cli): Improve header spacing by @NTaylorMullen in - [#18531](https://github.com/google-gemini/gemini-cli/pull/18531) -- Feature/quota visibility 16795 by @spencer426 in - [#18203](https://github.com/google-gemini/gemini-cli/pull/18203) -- Inline thinking bubbles with summary/full modes by @LyalinDotCom in - [#18033](https://github.com/google-gemini/gemini-cli/pull/18033) -- docs: remove TOC marker from Plan Mode header by @jerop in - [#18678](https://github.com/google-gemini/gemini-cli/pull/18678) -- fix(ui): remove redundant newlines in Gemini messages by @NTaylorMullen in - [#18538](https://github.com/google-gemini/gemini-cli/pull/18538) -- test(cli): fix AppContainer act() warnings and improve waitFor resilience by - @NTaylorMullen in - [#18676](https://github.com/google-gemini/gemini-cli/pull/18676) -- refactor(core): refine Security & System Integrity section in system prompt by - @NTaylorMullen in - [#18601](https://github.com/google-gemini/gemini-cli/pull/18601) -- Fix layout rounding. by @gundermanc in - [#18667](https://github.com/google-gemini/gemini-cli/pull/18667) -- docs(skills): enhance pr-creator safety and interactivity by @NTaylorMullen in - [#18616](https://github.com/google-gemini/gemini-cli/pull/18616) -- test(core): remove hardcoded model from TestRig by @NTaylorMullen in - [#18710](https://github.com/google-gemini/gemini-cli/pull/18710) -- feat(core): optimize sub-agents system prompt intro by @NTaylorMullen in - [#18608](https://github.com/google-gemini/gemini-cli/pull/18608) -- feat(cli): update approval mode labels and shortcuts per latest UX spec by - @jerop in [#18698](https://github.com/google-gemini/gemini-cli/pull/18698) -- fix(plan): update persistent approval mode setting by @Adib234 in - [#18638](https://github.com/google-gemini/gemini-cli/pull/18638) -- fix: move toasts location to left side by @jackwotherspoon in - [#18705](https://github.com/google-gemini/gemini-cli/pull/18705) -- feat(routing): restrict numerical routing to Gemini 3 family by @mattKorwel in - [#18478](https://github.com/google-gemini/gemini-cli/pull/18478) -- fix(ide): fix ide nudge setting by @skeshive in - [#18733](https://github.com/google-gemini/gemini-cli/pull/18733) -- fix(core): standardize tool formatting in system prompts by @NTaylorMullen in - [#18615](https://github.com/google-gemini/gemini-cli/pull/18615) -- chore: consolidate to green in ask user dialog by @jackwotherspoon in - [#18734](https://github.com/google-gemini/gemini-cli/pull/18734) -- feat: add `extensionsExplore` setting to enable extensions explore UI. by - @sripasg in [#18686](https://github.com/google-gemini/gemini-cli/pull/18686) -- feat(cli): defer devtools startup and integrate with F12 by @SandyTao520 in - [#18695](https://github.com/google-gemini/gemini-cli/pull/18695) -- ui: update & subdue footer colors and animate progress indicator by - @keithguerin in - [#18570](https://github.com/google-gemini/gemini-cli/pull/18570) -- test: add model-specific snapshots for coreTools by @aishaneeshah in - [#18707](https://github.com/google-gemini/gemini-cli/pull/18707) -- ci: shard windows tests and fix event listener leaks by @NTaylorMullen in - [#18670](https://github.com/google-gemini/gemini-cli/pull/18670) -- fix: allow `ask_user` tool in yolo mode by @jackwotherspoon in - [#18541](https://github.com/google-gemini/gemini-cli/pull/18541) -- feat: redact disabled tools from system prompt (#13597) by @NTaylorMullen in - [#18613](https://github.com/google-gemini/gemini-cli/pull/18613) -- Update Gemini.md to use the curent year on creating new files by @sehoon38 in - [#18460](https://github.com/google-gemini/gemini-cli/pull/18460) -- Code review cleanup for thinking display by @jacob314 in - [#18720](https://github.com/google-gemini/gemini-cli/pull/18720) -- fix(cli): hide scrollbars when in alternate buffer copy mode by @werdnum in - [#18354](https://github.com/google-gemini/gemini-cli/pull/18354) -- Fix issues with rip grep by @gundermanc in - [#18756](https://github.com/google-gemini/gemini-cli/pull/18756) -- fix(cli): fix history navigation regression after prompt autocomplete by - @sehoon38 in [#18752](https://github.com/google-gemini/gemini-cli/pull/18752) -- chore: cleanup unused and add unlisted dependencies in packages/cli 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 - [#18749](https://github.com/google-gemini/gemini-cli/pull/18749) -- Fix issue where Gemini CLI creates tests in a new file by @gundermanc in - [#18409](https://github.com/google-gemini/gemini-cli/pull/18409) -- feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs by - @kevin-ramdass in - [#18747](https://github.com/google-gemini/gemini-cli/pull/18747) -- fix(patch): cherry-pick e9a9474 to release/v0.29.0-preview.0-pr-18840 to patch - version v0.29.0-preview.0 and create version 0.29.0-preview.1 by + [#19526](https://github.com/google-gemini/gemini-cli/pull/19526) +- feat(cli): improve CTRL+O experience for both standard and alternate screen + buffer (ASB) modes by @jwhelangoog in + [#19010](https://github.com/google-gemini/gemini-cli/pull/19010) +- Utilize pipelining of grep_search -> read_file to eliminate turns by + @gundermanc in + [#19574](https://github.com/google-gemini/gemini-cli/pull/19574) +- refactor(core): remove unsafe type assertions in error utils (Phase 1.1) by + @mattKorwel in + [#19750](https://github.com/google-gemini/gemini-cli/pull/19750) +- Disallow unsafe returns. by @gundermanc in + [#19767](https://github.com/google-gemini/gemini-cli/pull/19767) +- fix(cli): filter subagent sessions from resume history by @abhipatel12 in + [#19698](https://github.com/google-gemini/gemini-cli/pull/19698) +- chore(lint): fix lint errors seen when running npm run lint by @abhipatel12 in + [#19844](https://github.com/google-gemini/gemini-cli/pull/19844) +- feat(core): remove unnecessary login verbiage from Code Assist auth by + @NTaylorMullen in + [#19861](https://github.com/google-gemini/gemini-cli/pull/19861) +- fix(plan): time share by approval mode dashboard reporting negative time + shares by @Adib234 in + [#19847](https://github.com/google-gemini/gemini-cli/pull/19847) +- fix(core): allow any preview model in quota access check by @bdmorgan in + [#19867](https://github.com/google-gemini/gemini-cli/pull/19867) +- fix(core): prevent omission placeholder deletions in replace/write_file by + @nsalerni in [#19870](https://github.com/google-gemini/gemini-cli/pull/19870) +- fix(core): add uniqueness guard to edit tool by @Shivangisharma4 in + [#19890](https://github.com/google-gemini/gemini-cli/pull/19890) +- refactor(config): remove enablePromptCompletion from settings by @sehoon38 in + [#19974](https://github.com/google-gemini/gemini-cli/pull/19974) +- refactor(core): move session conversion logic to core by @abhipatel12 in + [#19972](https://github.com/google-gemini/gemini-cli/pull/19972) +- Fix: Persist manual model selection on restart #19864 by @Nixxx19 in + [#19891](https://github.com/google-gemini/gemini-cli/pull/19891) +- fix(core): increase default retry attempts and add quota error backoff by + @sehoon38 in [#19949](https://github.com/google-gemini/gemini-cli/pull/19949) +- feat(core): add policy chain support for Gemini 3.1 by @sehoon38 in + [#19991](https://github.com/google-gemini/gemini-cli/pull/19991) +- Updates command reference and /stats command. by @g-samroberts in + [#19794](https://github.com/google-gemini/gemini-cli/pull/19794) +- Fix for silent failures in non-interactive mode by @owenofbrien in + [#19905](https://github.com/google-gemini/gemini-cli/pull/19905) +- fix(plan): allow plan mode writes on Windows and fix prompt paths by @Adib234 + in [#19658](https://github.com/google-gemini/gemini-cli/pull/19658) +- fix(core): prevent OAuth server crash on unexpected requests by @reyyanxahmed + in [#19668](https://github.com/google-gemini/gemini-cli/pull/19668) +- feat: Map tool kinds to explicit ACP.ToolKind values and update test … by + @sripasg in [#19547](https://github.com/google-gemini/gemini-cli/pull/19547) +- chore: restrict gemini-automted-issue-triage to only allow echo by @galz10 in + [#20047](https://github.com/google-gemini/gemini-cli/pull/20047) +- Allow ask headers longer than 16 chars by @scidomino in + [#20041](https://github.com/google-gemini/gemini-cli/pull/20041) +- fix(core): prevent state corruption in McpClientManager during collis by @h30s + in [#19782](https://github.com/google-gemini/gemini-cli/pull/19782) +- fix(bundling): copy devtools package to bundle for runtime resolution by + @SandyTao520 in + [#19766](https://github.com/google-gemini/gemini-cli/pull/19766) +- feat(policy): Support MCP Server Wildcards in Policy Engine by @jerop in + [#20024](https://github.com/google-gemini/gemini-cli/pull/20024) +- docs(CONTRIBUTING): update React DevTools version to 6 by @mmgok in + [#20014](https://github.com/google-gemini/gemini-cli/pull/20014) +- feat(core): optimize tool descriptions and schemas for Gemini 3 by + @aishaneeshah in + [#19643](https://github.com/google-gemini/gemini-cli/pull/19643) +- feat(core): implement experimental direct web fetch by @mbleigh in + [#19557](https://github.com/google-gemini/gemini-cli/pull/19557) +- feat(core): replace expected_replacements with allow_multiple in replace tool + by @SandyTao520 in + [#20033](https://github.com/google-gemini/gemini-cli/pull/20033) +- fix(sandbox): harden image packaging integrity checks by @aviralgarg05 in + [#19552](https://github.com/google-gemini/gemini-cli/pull/19552) +- fix(core): allow environment variable expansion and explicit overrides for MCP + servers by @galz10 in + [#18837](https://github.com/google-gemini/gemini-cli/pull/18837) +- feat(policy): Implement Tool Annotation Matching in Policy Engine by @jerop in + [#20029](https://github.com/google-gemini/gemini-cli/pull/20029) +- fix(core): prevent utility calls from changing session active model by + @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) +- 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 - [#18841](https://github.com/google-gemini/gemini-cli/pull/18841) -- fix(patch): cherry-pick 08e8eea to release/v0.29.0-preview.1-pr-18855 to patch - version v0.29.0-preview.1 and create version 0.29.0-preview.2 by + [#20568](https://github.com/google-gemini/gemini-cli/pull/20568) +- fix(patch): cherry-pick ea48bd9 to release/v0.31.0-preview.1-pr-20577 + [CONFLICTS] by @gemini-cli-robot in + [#20592](https://github.com/google-gemini/gemini-cli/pull/20592) +- fix(patch): cherry-pick 32e777f to release/v0.31.0-preview.2-pr-20531 to patch + version v0.31.0-preview.2 and create version 0.31.0-preview.3 by @gemini-cli-robot in - [#18905](https://github.com/google-gemini/gemini-cli/pull/18905) -- fix(patch): cherry-pick d0c6a56 to release/v0.29.0-preview.2-pr-18976 to patch - version v0.29.0-preview.2 and create version 0.29.0-preview.3 by - @gemini-cli-robot in - [#19023](https://github.com/google-gemini/gemini-cli/pull/19023) -- fix(patch): cherry-pick e5ff202 to release/v0.29.0-preview.3-pr-19254 to patch - version v0.29.0-preview.3 and create version 0.29.0-preview.4 by - @gemini-cli-robot in - [#19264](https://github.com/google-gemini/gemini-cli/pull/19264) -- fix(patch): cherry-pick 9590a09 to release/v0.29.0-preview.4-pr-18771 to patch - version v0.29.0-preview.4 and create version 0.29.0-preview.5 by - @gemini-cli-robot in - [#19274](https://github.com/google-gemini/gemini-cli/pull/19274) + [#20607](https://github.com/google-gemini/gemini-cli/pull/20607) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.28.2...v0.29.0 +https://github.com/google-gemini/gemini-cli/compare/v0.30.1...v0.31.0 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 646106fa50..b08f4fa1b0 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.32.0-preview.0 -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,196 @@ 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**: Significant updates to Plan Mode, including + support for modifying plans in external editors, adaptive workflows based on + task complexity, and new integration tests. +- **Agent and Core Engine Updates**: Enabled the generalist agent, introduced + `Kind.Agent` for sub-agent classification, implemented task tracking + foundation, and improved Agent-to-Agent (A2A) streaming and content + extraction. +- **CLI & User Experience**: Introduced interactive shell autocompletion, added + a new verbosity mode for cleaner error reporting, enabled parallel loading of + extensions, and improved UI hints and shortcut handling. +- **Billing and Security**: Implemented G1 AI credits overage flow with enhanced + billing telemetry, updated the authentication handshake to specification, and + added support for a policy engine in extensions. +- **Stability and Bug Fixes**: Addressed numerous issues including 100% CPU + consumption by orphaned processes, enhanced retry logic for Code Assist, + reduced intrusive MCP errors, and merged duplicate imports across packages. ## 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 - @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 +- feat(plan): add integration tests for plan mode by @Adib234 in + [#20214](https://github.com/google-gemini/gemini-cli/pull/20214) +- fix(acp): update auth handshake to spec by @skeshive in + [#19725](https://github.com/google-gemini/gemini-cli/pull/19725) +- feat(core): implement robust A2A streaming reassembly and fix task continuity + by @adamfweidman in + [#20091](https://github.com/google-gemini/gemini-cli/pull/20091) +- feat(cli): load extensions in parallel by @scidomino in + [#20229](https://github.com/google-gemini/gemini-cli/pull/20229) +- Plumb the maxAttempts setting through Config args by @kevinjwang1 in + [#20239](https://github.com/google-gemini/gemini-cli/pull/20239) +- fix(cli): skip 404 errors in setup-github file downloads by @h30s in + [#20287](https://github.com/google-gemini/gemini-cli/pull/20287) +- fix(cli): expose model.name setting in settings dialog for persistence by + @achaljhawar in + [#19605](https://github.com/google-gemini/gemini-cli/pull/19605) +- docs: remove legacy cmd examples in favor of powershell by @scidomino in + [#20323](https://github.com/google-gemini/gemini-cli/pull/20323) +- feat(core): Enable model steering in workspace. by @joshualitt in + [#20343](https://github.com/google-gemini/gemini-cli/pull/20343) +- fix: remove trailing comma in issue triage workflow settings json by @Nixxx19 + in [#20265](https://github.com/google-gemini/gemini-cli/pull/20265) +- feat(core): implement task tracker foundation and service by @anj-s in + [#19464](https://github.com/google-gemini/gemini-cli/pull/19464) +- test: support tests that include color information by @jacob314 in + [#20220](https://github.com/google-gemini/gemini-cli/pull/20220) +- feat(core): introduce Kind.Agent for sub-agent classification by @abhipatel12 + in [#20369](https://github.com/google-gemini/gemini-cli/pull/20369) +- Changelog for v0.30.0 by @gemini-cli-robot in + [#20252](https://github.com/google-gemini/gemini-cli/pull/20252) +- Update changelog workflow to reject nightly builds by @g-samroberts in + [#20248](https://github.com/google-gemini/gemini-cli/pull/20248) +- Changelog for v0.31.0-preview.0 by @gemini-cli-robot in + [#20249](https://github.com/google-gemini/gemini-cli/pull/20249) +- feat(cli): hide workspace policy update dialog and auto-accept by default 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 + [#20351](https://github.com/google-gemini/gemini-cli/pull/20351) +- feat(core): rename grep_search include parameter to include_pattern by @SandyTao520 in - [#18795](https://github.com/google-gemini/gemini-cli/pull/18795) -- feat(masking): enable tool output masking by default by @abhipatel12 in - [#18564](https://github.com/google-gemini/gemini-cli/pull/18564) -- perf(ui): optimize table rendering by memoizing styled characters by @devr0306 - in [#18770](https://github.com/google-gemini/gemini-cli/pull/18770) -- feat: multi-line text answers in ask-user tool by @jackwotherspoon in - [#18741](https://github.com/google-gemini/gemini-cli/pull/18741) -- perf(cli): truncate large debug logs and limit message history by @mattKorwel - in [#18663](https://github.com/google-gemini/gemini-cli/pull/18663) -- fix(core): complete MCP discovery when configured servers are skipped by - @LyalinDotCom in - [#18586](https://github.com/google-gemini/gemini-cli/pull/18586) -- fix(core): cache CLI version to ensure consistency during sessions by - @sehoon38 in [#18793](https://github.com/google-gemini/gemini-cli/pull/18793) -- fix(cli): resolve double rendering in shpool and address vscode lint warnings - by @braddux in - [#18704](https://github.com/google-gemini/gemini-cli/pull/18704) -- feat(plan): document and validate Plan Mode policy overrides by @jerop in - [#18825](https://github.com/google-gemini/gemini-cli/pull/18825) -- Fix pressing any key to exit select mode. by @jacob314 in - [#18421](https://github.com/google-gemini/gemini-cli/pull/18421) -- fix(cli): update F12 behavior to only open drawer if browser fails by - @SandyTao520 in - [#18829](https://github.com/google-gemini/gemini-cli/pull/18829) -- feat(plan): allow skills to be enabled in plan mode by @Adib234 in - [#18817](https://github.com/google-gemini/gemini-cli/pull/18817) -- docs(plan): add documentation for plan mode tools by @jerop in - [#18827](https://github.com/google-gemini/gemini-cli/pull/18827) -- Remove experimental note in extension settings docs by @chrstnb in - [#18822](https://github.com/google-gemini/gemini-cli/pull/18822) -- Update prompt and grep tool definition to limit context size by @gundermanc in - [#18780](https://github.com/google-gemini/gemini-cli/pull/18780) -- docs(plan): add `ask_user` tool documentation by @jerop in - [#18830](https://github.com/google-gemini/gemini-cli/pull/18830) -- Revert unintended credentials exposure by @Adib234 in - [#18840](https://github.com/google-gemini/gemini-cli/pull/18840) -- feat(core): update internal utility models to Gemini 3 by @SandyTao520 in - [#18773](https://github.com/google-gemini/gemini-cli/pull/18773) -- feat(a2a): add value-resolver for auth credential resolution by @adamfweidman - in [#18653](https://github.com/google-gemini/gemini-cli/pull/18653) -- Removed getPlainTextLength by @devr0306 in - [#18848](https://github.com/google-gemini/gemini-cli/pull/18848) -- More grep prompt tweaks by @gundermanc in - [#18846](https://github.com/google-gemini/gemini-cli/pull/18846) -- refactor(cli): Reactive useSettingsStore hook by @psinha40898 in - [#14915](https://github.com/google-gemini/gemini-cli/pull/14915) -- fix(mcp): Ensure that stdio MCP server execution has the `GEMINI_CLI=1` env - variable populated. by @richieforeman in - [#18832](https://github.com/google-gemini/gemini-cli/pull/18832) -- fix(core): improve headless mode detection for flags and query args by @galz10 - in [#18855](https://github.com/google-gemini/gemini-cli/pull/18855) -- refactor(cli): simplify UI and remove legacy inline tool confirmation logic by - @abhipatel12 in - [#18566](https://github.com/google-gemini/gemini-cli/pull/18566) -- feat(cli): deprecate --allowed-tools and excludeTools in favor of policy - engine by @Abhijit-2592 in - [#18508](https://github.com/google-gemini/gemini-cli/pull/18508) -- fix(workflows): improve maintainer detection for automated PR actions by - @bdmorgan in [#18869](https://github.com/google-gemini/gemini-cli/pull/18869) -- refactor(cli): consolidate useToolScheduler and delete legacy implementation - by @abhipatel12 in - [#18567](https://github.com/google-gemini/gemini-cli/pull/18567) -- Update changelog for v0.28.0 and v0.29.0-preview0 by @g-samroberts in - [#18819](https://github.com/google-gemini/gemini-cli/pull/18819) -- fix(core): ensure sub-agents are registered regardless of tools.allowed by - @mattKorwel in - [#18870](https://github.com/google-gemini/gemini-cli/pull/18870) -- Show notification when there's a conflict with an extensions command by - @chrstnb in [#17890](https://github.com/google-gemini/gemini-cli/pull/17890) -- fix(cli): dismiss '?' shortcuts help on hotkeys and active states by - @LyalinDotCom in - [#18583](https://github.com/google-gemini/gemini-cli/pull/18583) -- fix(core): prioritize conditional policy rules and harden Plan Mode by - @Abhijit-2592 in - [#18882](https://github.com/google-gemini/gemini-cli/pull/18882) -- feat(core): refine Plan Mode system prompt for agentic execution by - @NTaylorMullen in - [#18799](https://github.com/google-gemini/gemini-cli/pull/18799) -- feat(plan): create metrics for usage of `AskUser` tool by @Adib234 in - [#18820](https://github.com/google-gemini/gemini-cli/pull/18820) -- feat(cli): support Ctrl-Z suspension by @scidomino in - [#18931](https://github.com/google-gemini/gemini-cli/pull/18931) -- fix(github-actions): use robot PAT for release creation to trigger release - notes by @SandyTao520 in - [#18794](https://github.com/google-gemini/gemini-cli/pull/18794) -- feat: add strict seatbelt profiles and remove unusable closed profiles by - @SandyTao520 in - [#18876](https://github.com/google-gemini/gemini-cli/pull/18876) -- chore: cleanup unused and add unlisted dependencies in packages/a2a-server by - @adamfweidman in - [#18916](https://github.com/google-gemini/gemini-cli/pull/18916) -- fix(plan): isolate plan files per session by @Adib234 in - [#18757](https://github.com/google-gemini/gemini-cli/pull/18757) -- fix: character truncation in raw markdown mode by @jackwotherspoon in - [#18938](https://github.com/google-gemini/gemini-cli/pull/18938) -- feat(cli): prototype clean UI toggle and minimal-mode bleed-through by - @LyalinDotCom in - [#18683](https://github.com/google-gemini/gemini-cli/pull/18683) -- ui(polish) blend background color with theme by @jacob314 in - [#18802](https://github.com/google-gemini/gemini-cli/pull/18802) -- Add generic searchable list to back settings and extensions by @chrstnb in - [#18838](https://github.com/google-gemini/gemini-cli/pull/18838) -- feat(ui): align `AskUser` color scheme with UX spec by @jerop in - [#18943](https://github.com/google-gemini/gemini-cli/pull/18943) -- Hide AskUser tool validation errors from UI (agent self-corrects) by @jerop in - [#18954](https://github.com/google-gemini/gemini-cli/pull/18954) -- bug(cli) fix flicker due to AppContainer continuous initialization by - @jacob314 in [#18958](https://github.com/google-gemini/gemini-cli/pull/18958) -- feat(admin): Add admin controls documentation by @skeshive in - [#18644](https://github.com/google-gemini/gemini-cli/pull/18644) -- feat(cli): disable ctrl-s shortcut outside of alternate buffer mode by - @jacob314 in [#18887](https://github.com/google-gemini/gemini-cli/pull/18887) -- fix(vim): vim support that feels (more) complete by @ppgranger in - [#18755](https://github.com/google-gemini/gemini-cli/pull/18755) -- feat(policy): add --policy flag for user defined policies by @allenhutchison - in [#18500](https://github.com/google-gemini/gemini-cli/pull/18500) -- Update installation guide by @g-samroberts in - [#18823](https://github.com/google-gemini/gemini-cli/pull/18823) -- refactor(core): centralize tool definitions (Group 1: replace, search, grep) - by @aishaneeshah in - [#18944](https://github.com/google-gemini/gemini-cli/pull/18944) -- refactor(cli): finalize event-driven transition and remove interaction bridge - by @abhipatel12 in - [#18569](https://github.com/google-gemini/gemini-cli/pull/18569) -- Fix drag and drop escaping by @scidomino in - [#18965](https://github.com/google-gemini/gemini-cli/pull/18965) -- feat(sdk): initial package bootstrap for SDK by @mbleigh in - [#18861](https://github.com/google-gemini/gemini-cli/pull/18861) -- feat(sdk): implements SessionContext for SDK tool calls by @mbleigh in - [#18862](https://github.com/google-gemini/gemini-cli/pull/18862) -- fix(plan): make question type required in AskUser tool by @Adib234 in - [#18959](https://github.com/google-gemini/gemini-cli/pull/18959) -- fix(core): ensure --yolo does not force headless mode by @NTaylorMullen in - [#18976](https://github.com/google-gemini/gemini-cli/pull/18976) -- refactor(core): adopt `CoreToolCallStatus` enum for type safety by @jerop in - [#18998](https://github.com/google-gemini/gemini-cli/pull/18998) -- Enable in-CLI extension management commands for team by @chrstnb in - [#18957](https://github.com/google-gemini/gemini-cli/pull/18957) -- Adjust lint rules to avoid unnecessary warning. by @scidomino in - [#18970](https://github.com/google-gemini/gemini-cli/pull/18970) -- fix(vscode): resolve unsafe type assertion lint errors by @ehedlund in - [#19006](https://github.com/google-gemini/gemini-cli/pull/19006) -- Remove unnecessary eslint config file by @scidomino in - [#19015](https://github.com/google-gemini/gemini-cli/pull/19015) -- fix(core): Prevent loop detection false positives on lists with long shared - prefixes by @SandyTao520 in - [#18975](https://github.com/google-gemini/gemini-cli/pull/18975) -- feat(core): fallback to chat-base when using unrecognized models for chat by - @SandyTao520 in - [#19016](https://github.com/google-gemini/gemini-cli/pull/19016) -- docs: fix inconsistent commandRegex example in policy engine by @NTaylorMullen - in [#19027](https://github.com/google-gemini/gemini-cli/pull/19027) -- fix(plan): persist the approval mode in UI even when agent is thinking by - @Adib234 in [#18955](https://github.com/google-gemini/gemini-cli/pull/18955) -- feat(sdk): Implement dynamic system instructions by @mbleigh in - [#18863](https://github.com/google-gemini/gemini-cli/pull/18863) -- Docs: Refresh docs to organize and standardize reference materials. by - @jkcinouye in [#18403](https://github.com/google-gemini/gemini-cli/pull/18403) -- fix windows escaping (and broken tests) by @scidomino in - [#19011](https://github.com/google-gemini/gemini-cli/pull/19011) -- refactor: use `CoreToolCallStatus` in the the history data model by @jerop in - [#19033](https://github.com/google-gemini/gemini-cli/pull/19033) -- feat(cleanup): enable 30-day session retention by default by @skeshive in - [#18854](https://github.com/google-gemini/gemini-cli/pull/18854) -- feat(plan): hide plan write and edit operations on plans in Plan Mode by - @jerop in [#19012](https://github.com/google-gemini/gemini-cli/pull/19012) -- bug(ui) fix flicker refreshing background color by @jacob314 in - [#19041](https://github.com/google-gemini/gemini-cli/pull/19041) -- chore: fix dep vulnerabilities by @scidomino in - [#19036](https://github.com/google-gemini/gemini-cli/pull/19036) -- Revamp automated changelog skill by @g-samroberts in - [#18974](https://github.com/google-gemini/gemini-cli/pull/18974) -- feat(sdk): implement support for custom skills by @mbleigh in - [#19031](https://github.com/google-gemini/gemini-cli/pull/19031) -- refactor(core): complete centralization of core tool definitions by - @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 + [#20328](https://github.com/google-gemini/gemini-cli/pull/20328) +- feat(plan): support opening and modifying plan in external editor by @Adib234 + in [#20348](https://github.com/google-gemini/gemini-cli/pull/20348) +- feat(cli): implement interactive shell autocompletion by @mrpmohiburrahman in + [#20082](https://github.com/google-gemini/gemini-cli/pull/20082) +- fix(core): allow /memory add to work in plan mode by @Jefftree in + [#20353](https://github.com/google-gemini/gemini-cli/pull/20353) +- feat(core): add HTTP 499 to retryable errors and map to RetryableQuotaError by + @bdmorgan in [#20432](https://github.com/google-gemini/gemini-cli/pull/20432) +- feat(core): Enable generalist agent by @joshualitt in + [#19665](https://github.com/google-gemini/gemini-cli/pull/19665) +- Updated tests in TableRenderer.test.tsx to use SVG snapshots by @devr0306 in + [#20450](https://github.com/google-gemini/gemini-cli/pull/20450) +- Refactor Github Action per b/485167538 by @google-admin in + [#19443](https://github.com/google-gemini/gemini-cli/pull/19443) +- fix(github): resolve actionlint and yamllint regressions from #19443 by @jerop + in [#20467](https://github.com/google-gemini/gemini-cli/pull/20467) +- fix: action var usage by @galz10 in + [#20492](https://github.com/google-gemini/gemini-cli/pull/20492) +- feat(core): improve A2A content extraction by @adamfweidman in + [#20487](https://github.com/google-gemini/gemini-cli/pull/20487) +- fix(cli): support quota error fallbacks for all authentication types by + @sehoon38 in [#20475](https://github.com/google-gemini/gemini-cli/pull/20475) +- fix(core): flush transcript for pure tool-call responses to ensure BeforeTool + hooks see complete state by @krishdef7 in + [#20419](https://github.com/google-gemini/gemini-cli/pull/20419) +- feat(plan): adapt planning workflow based on complexity of task by @jerop in + [#20465](https://github.com/google-gemini/gemini-cli/pull/20465) +- fix: prevent orphaned processes from consuming 100% CPU when terminal closes + by @yuvrajangadsingh in + [#16965](https://github.com/google-gemini/gemini-cli/pull/16965) +- feat(core): increase fetch timeout and fix [object Object] error + stringification by @bdmorgan in + [#20441](https://github.com/google-gemini/gemini-cli/pull/20441) +- [Gemma x Gemini CLI] Add an Experimental Gemma Router that uses a LiteRT-LM + shim into the Composite Model Classifier Strategy by @sidwan02 in + [#17231](https://github.com/google-gemini/gemini-cli/pull/17231) +- docs(plan): update documentation regarding supporting editing of plan files + during plan approval by @Adib234 in + [#20452](https://github.com/google-gemini/gemini-cli/pull/20452) +- test(cli): fix flaky ToolResultDisplay overflow test by @jwhelangoog in + [#20518](https://github.com/google-gemini/gemini-cli/pull/20518) +- ui(cli): reduce length of Ctrl+O hint by @jwhelangoog in + [#20490](https://github.com/google-gemini/gemini-cli/pull/20490) +- fix(ui): correct styled table width calculations by @devr0306 in + [#20042](https://github.com/google-gemini/gemini-cli/pull/20042) +- Avoid overaggressive unescaping by @scidomino in + [#20520](https://github.com/google-gemini/gemini-cli/pull/20520) +- feat(telemetry) Instrument traces with more attributes and make them available + to OTEL users by @heaventourist in + [#20237](https://github.com/google-gemini/gemini-cli/pull/20237) +- Add support for policy engine in extensions by @chrstnb in + [#20049](https://github.com/google-gemini/gemini-cli/pull/20049) +- Docs: Update to Terms of Service & FAQ by @jkcinouye in + [#20488](https://github.com/google-gemini/gemini-cli/pull/20488) +- Fix bottom border rendering for search and add a regression test. by @jacob314 + in [#20517](https://github.com/google-gemini/gemini-cli/pull/20517) +- fix(core): apply retry logic to CodeAssistServer for all users by @bdmorgan in + [#20507](https://github.com/google-gemini/gemini-cli/pull/20507) +- Fix extension MCP server env var loading by @chrstnb in + [#20374](https://github.com/google-gemini/gemini-cli/pull/20374) +- feat(ui): add 'ctrl+o' hint to truncated content message by @jerop in + [#20529](https://github.com/google-gemini/gemini-cli/pull/20529) +- Fix flicker showing message to press ctrl-O again to collapse. by @jacob314 in + [#20414](https://github.com/google-gemini/gemini-cli/pull/20414) +- fix(cli): hide shortcuts hint while model is thinking or the user has typed a + prompt + add debounce to avoid flicker by @jacob314 in + [#19389](https://github.com/google-gemini/gemini-cli/pull/19389) +- feat(plan): update planning workflow to encourage multi-select with + descriptions of options by @Adib234 in + [#20491](https://github.com/google-gemini/gemini-cli/pull/20491) +- refactor(core,cli): useAlternateBuffer read from config by @psinha40898 in + [#20346](https://github.com/google-gemini/gemini-cli/pull/20346) +- fix(cli): ensure dialogs stay scrolled to bottom in alternate buffer mode by + @jacob314 in [#20527](https://github.com/google-gemini/gemini-cli/pull/20527) +- fix(core): revert auto-save of policies to user space by @Abhijit-2592 in + [#20531](https://github.com/google-gemini/gemini-cli/pull/20531) +- Demote unreliable test. by @gundermanc in + [#20571](https://github.com/google-gemini/gemini-cli/pull/20571) +- fix(core): handle optional response fields from code assist API by @sehoon38 + in [#20345](https://github.com/google-gemini/gemini-cli/pull/20345) +- fix(cli): keep thought summary when loading phrases are off by @LyalinDotCom + in [#20497](https://github.com/google-gemini/gemini-cli/pull/20497) +- feat(cli): add temporary flag to disable workspace policies by @Abhijit-2592 + in [#20523](https://github.com/google-gemini/gemini-cli/pull/20523) +- Disable expensive and scheduled workflows on personal forks by @dewitt in + [#20449](https://github.com/google-gemini/gemini-cli/pull/20449) +- Moved markdown parsing logic to a separate util file by @devr0306 in + [#20526](https://github.com/google-gemini/gemini-cli/pull/20526) +- fix(plan): prevent agent from using ask_user for shell command confirmation by + @Adib234 in [#20504](https://github.com/google-gemini/gemini-cli/pull/20504) +- fix(core): disable retries for code assist streaming requests by @sehoon38 in + [#20561](https://github.com/google-gemini/gemini-cli/pull/20561) +- feat(billing): implement G1 AI credits overage flow with billing telemetry 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 + [#18590](https://github.com/google-gemini/gemini-cli/pull/18590) +- feat: better error messages by @gsquared94 in + [#20577](https://github.com/google-gemini/gemini-cli/pull/20577) +- fix(ui): persist expansion in AskUser dialog when navigating options by @jerop + in [#20559](https://github.com/google-gemini/gemini-cli/pull/20559) +- fix(cli): prevent sub-agent tool calls from leaking into UI by @abhipatel12 in + [#20580](https://github.com/google-gemini/gemini-cli/pull/20580) +- fix(cli): Shell autocomplete polish by @jacob314 in + [#20411](https://github.com/google-gemini/gemini-cli/pull/20411) +- Changelog for v0.31.0-preview.1 by @gemini-cli-robot in + [#20590](https://github.com/google-gemini/gemini-cli/pull/20590) +- Add slash command for promoting behavioral evals to CI blocking by @gundermanc + in [#20575](https://github.com/google-gemini/gemini-cli/pull/20575) +- Changelog for v0.30.1 by @gemini-cli-robot in + [#20589](https://github.com/google-gemini/gemini-cli/pull/20589) +- Add low/full CLI error verbosity mode for cleaner UI by @LyalinDotCom in + [#20399](https://github.com/google-gemini/gemini-cli/pull/20399) +- Disable Gemini PR reviews on draft PRs. by @gundermanc in + [#20362](https://github.com/google-gemini/gemini-cli/pull/20362) +- Docs: FAQ update by @jkcinouye in + [#20585](https://github.com/google-gemini/gemini-cli/pull/20585) +- fix(core): reduce intrusive MCP errors and deduplicate diagnostics by + @spencer426 in + [#20232](https://github.com/google-gemini/gemini-cli/pull/20232) +- docs: fix spelling typos in installation guide by @campox747 in + [#20579](https://github.com/google-gemini/gemini-cli/pull/20579) +- Promote stable tests to CI blocking. by @gundermanc in + [#20581](https://github.com/google-gemini/gemini-cli/pull/20581) +- feat(core): enable contiguous parallel admission for Kind.Agent tools 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) + [#20583](https://github.com/google-gemini/gemini-cli/pull/20583) +- Enforce import/no-duplicates as error by @Nixxx19 in + [#19797](https://github.com/google-gemini/gemini-cli/pull/19797) +- fix: merge duplicate imports in sdk and test-utils packages (1/4) by @Nixxx19 + in [#19777](https://github.com/google-gemini/gemini-cli/pull/19777) +- fix: merge duplicate imports in a2a-server package (2/4) by @Nixxx19 in + [#19781](https://github.com/google-gemini/gemini-cli/pull/19781) -**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.31.0-preview.3...v0.32.0-preview.0 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..a8511d9c42 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -25,9 +25,10 @@ implementation. It allows you to: - [Customizing Planning with Skills](#customizing-planning-with-skills) - [Customizing Policies](#customizing-policies) - [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode) - - [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode) + - [Example: Enable custom subagents in Plan Mode](#example-enable-custom-subagents-in-plan-mode) - [Custom Plan Directory and Policies](#custom-plan-directory-and-policies) - [Automatic Model Routing](#automatic-model-routing) +- [Cleanup](#cleanup) ## Enabling Plan Mode @@ -80,18 +81,38 @@ 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. If you make any changes and save the file, the CLI + will automatically send the updated plan back to the agent for review and + iteration. For more complex or specialized planning tasks, you can [customize the planning workflow with skills](#customizing-planning-with-skills). @@ -113,12 +134,14 @@ These are the only allowed tools: - **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`] - **Search:** [`grep_search`], [`google_web_search`] +- **Research Subagents:** [`codebase_investigator`], [`cli_help`] - **Interaction:** [`ask_user`] - **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`, `postgres_read_schema`) are allowed. - **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md` 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) @@ -182,16 +205,17 @@ priority = 100 modes = ["plan"] ``` -#### Example: Enable research subagents in Plan Mode +#### Example: Enable custom subagents in Plan Mode -You can enable experimental research [subagents] like `codebase_investigator` to -help gather architecture details during the planning phase. +Built-in research [subagents] like [`codebase_investigator`] and [`cli_help`] +are enabled by default in Plan Mode. You can enable additional [custom +subagents] by adding a rule to your policy. `~/.gemini/policies/research-subagents.toml` ```toml [[rule]] -toolName = "codebase_investigator" +toolName = "my_custom_subagent" decision = "allow" priority = 100 modes = ["plan"] @@ -269,6 +293,24 @@ performance. You can disable this automatic switching in your settings: } ``` +## Cleanup + +By default, Gemini CLI automatically cleans up old session data, including all +associated plan files and task trackers. + +- **Default behavior:** Sessions (and their plans) are retained for **30 days**. +- **Configuration:** You can customize this behavior via the `/settings` command + (search for **Session Retention**) or in your `settings.json` file. See + [session retention] for more details. + +Manual deletion also removes all associated artifacts: + +- **Command Line:** Use `gemini --delete-session `. +- **Session Browser:** Press `/resume`, navigate to a session, and press `x`. + +If you use a [custom plans directory](#custom-plan-directory-and-policies), +those files are not automatically deleted and must be managed manually. + [`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder [`read_file`]: /docs/tools/file-system.md#2-read_file-readfile [`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext @@ -277,8 +319,12 @@ 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 +[`codebase_investigator`]: /docs/core/subagents.md#codebase_investigator +[`cli_help`]: /docs/core/subagents.md#cli_help [subagents]: /docs/core/subagents.md +[custom subagents]: /docs/core/subagents.md#creating-custom-subagents [policy engine]: /docs/reference/policy-engine.md [`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode [`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode @@ -288,3 +334,5 @@ 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 +[session retention]: /docs/cli/session-management.md#session-retention 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/session-management.md b/docs/cli/session-management.md index a1453148ae..442069bdac 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -121,27 +121,36 @@ session lengths. ### Session retention -To prevent your history from growing indefinitely, enable automatic cleanup -policies in your settings. +By default, Gemini CLI automatically cleans up old session data to prevent your +history from growing indefinitely. When a session is deleted, Gemini CLI also +removes all associated data, including implementation plans, task trackers, tool +outputs, and activity logs. + +The default policy is to **retain sessions for 30 days**. + +#### Configuration + +You can customize these policies using the `/settings` command or by manually +editing your `settings.json` file: ```json { "general": { "sessionRetention": { "enabled": true, - "maxAge": "30d", // Keep sessions for 30 days - "maxCount": 50 // Keep the 50 most recent sessions + "maxAge": "30d", + "maxCount": 50 } } } ``` - **`enabled`**: (boolean) Master switch for session cleanup. Defaults to - `false`. + `true`. - **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d", - "4w"). Sessions older than this are deleted. + "4w"). Sessions older than this are deleted. Defaults to `"30d"`. - **`maxCount`**: (number) Maximum number of sessions to retain. The oldest - sessions exceeding this count are deleted. + sessions exceeding this count are deleted. Defaults to undefined (unlimited). - **`minRetention`**: (string) Minimum retention period (safety limit). Defaults to `"1d"`. Sessions newer than this period are never deleted by automatic cleanup. diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 8adccba6ae..37508fc04e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -32,8 +32,8 @@ they appear in the UI. | Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | | Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | -| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | +| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | ### Output @@ -60,7 +60,7 @@ they appear in the UI. | Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` | | Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` | | Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` | -| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` | +| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` | | Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` | | Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` | | Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | @@ -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,15 +81,21 @@ they appear in the UI. | -------- | ------------- | ---------------------------- | ------- | | IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` | +### Billing + +| UI Label | Setting | Description | Default | +| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` | + ### Model -| UI Label | Setting | Description | Default | -| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | -| Model | `model.name` | The Gemini model to use for conversations. | `undefined` | -| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | -| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | -| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | -| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | +| UI Label | Setting | Description | Default | +| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- | +| Model | `model.name` | The Gemini model to use for conversations. | `undefined` | +| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | +| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` | +| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | +| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | ### Context @@ -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..46d43225b2 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -122,7 +122,10 @@ The manifest file defines the extension's behavior and configuration. } }, "contextFileName": "GEMINI.md", - "excludeTools": ["run_shell_command"] + "excludeTools": ["run_shell_command"], + "plan": { + "directory": ".gemini/plans" + } } ``` @@ -157,6 +160,11 @@ The manifest file defines the extension's behavior and configuration. `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. +- `plan`: Planning features configuration. + - `directory`: The directory where planning artifacts are stored. This serves + as a fallback if the user hasn't specified a plan directory in their + settings. If not specified by either the extension or the user, the default + is `~/.gemini/tmp///plans/`. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes @@ -227,6 +235,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..49954da8c6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -159,12 +159,12 @@ their corresponding top-level category object in your `settings.json` file. - **`general.sessionRetention.enabled`** (boolean): - **Description:** Enable automatic session cleanup - - **Default:** `false` + - **Default:** `true` - **`general.sessionRetention.maxAge`** (string): - **Description:** Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") - - **Default:** `undefined` + - **Default:** `"30d"` - **`general.sessionRetention.maxCount`** (number): - **Description:** Alternative: Maximum number of sessions to keep (most @@ -175,11 +175,6 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Minimum retention period (safety limit, defaults to "1d") - **Default:** `"1d"` -- **`general.sessionRetention.warningAcknowledged`** (boolean): - - **Description:** INTERNAL: Whether the user has acknowledged the session - retention warning - - **Default:** `false` - #### `output` - **`output.format`** (enum): @@ -268,7 +263,7 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`ui.footer.hideContextPercentage`** (boolean): - - **Description:** Hides the context window remaining percentage. + - **Description:** Hides the context window usage percentage. - **Default:** `true` - **`ui.hideFooter`** (boolean): @@ -322,6 +317,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 +358,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 +1024,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 +1327,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 +1340,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 +1358,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 +1402,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..e5691c43ee 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 @@ -152,3 +152,13 @@ available combinations. inline when the cursor is over the placeholder. - `Double-click` on a paste placeholder (alternate buffer mode only): Expand to view full content inline. Double-click again to collapse. + +## Limitations + +- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal): + - `shift+enter` is not supported. + - `shift+tab` + [is not supported](https://github.com/google-gemini/gemini-cli/issues/20314) + on Node 20 and earlier versions of Node 22. +- On macOS's [Terminal](): + - `shift+enter` is not supported. diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index a123634581..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/esbuild.config.js b/esbuild.config.js index 3ecf678088..49d158ec36 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -88,6 +88,9 @@ const cliConfig = { outfile: 'bundle/gemini.js', define: { 'process.env.CLI_VERSION': JSON.stringify(pkg.version), + 'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify( + pkg.config?.sandboxImageUri, + ), }, plugins: createWasmPlugins(), alias: { diff --git a/eslint.config.js b/eslint.config.js index 3bc350d027..d305f75f87 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,6 +25,18 @@ const __dirname = path.dirname(__filename); const projectRoot = __dirname; const currentYear = new Date().getFullYear(); +const commonRestrictedSyntaxRules = [ + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, +]; + export default tseslint.config( { // Global ignores @@ -55,26 +67,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 +89,11 @@ export default tseslint.config( }, }, rules: { + ...importPlugin.configs.recommended.rules, + ...importPlugin.configs.typescript.rules, + 'import/no-default-export': 'warn', + 'import/no-unresolved': 'off', + 'import/no-duplicates': 'error', // General Best Practice Rules (subset adapted for flat config) '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 'arrow-body-style': ['error', 'as-needed'], @@ -133,18 +132,7 @@ export default tseslint.config( 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, - ], + 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], 'no-unsafe-finally': 'error', 'no-unused-expressions': 'off', // Disable base rule '@typescript-eslint/no-unused-expressions': [ @@ -184,6 +172,28 @@ export default tseslint.config( ], }, }, + { + // API Response Optionality enforcement for Code Assist + files: ['packages/core/src/code_assist/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + ...commonRestrictedSyntaxRules, + { + selector: + 'TSInterfaceDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', + message: + 'All fields in API response interfaces (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', + }, + { + selector: + 'TSTypeAliasDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])', + message: + 'All fields in API response types (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.', + }, + ], + }, + }, { // Rules that only apply to product code files: ['packages/*/src/**/*.{ts,tsx}'], diff --git a/evals/README.md b/evals/README.md index eb3cf2be70..6cfecbad07 100644 --- a/evals/README.md +++ b/evals/README.md @@ -3,7 +3,8 @@ Behavioral evaluations (evals) are tests designed to validate the agent's behavior in response to specific prompts. They serve as a critical feedback loop for changes to system prompts, tool definitions, and other model-steering -mechanisms. +mechanisms, and as a tool for assessing feature reliability by model, and +preventing regressions. ## Why Behavioral Evals? @@ -30,6 +31,48 @@ CLI's features. those that are generally reliable but might occasionally vary (`USUALLY_PASSES`). +## Best Practices + +When designing behavioral evals, aim for scenarios that accurately reflect +real-world usage while remaining small and maintainable. + +- **Realistic Complexity**: Evals should be complicated enough to be + "realistic." They should operate on actual files and a source directory, + mirroring how a real agent interacts with a workspace. Remember that the agent + may behave differently in a larger codebase, so we want to avoid scenarios + that are too simple to be realistic. + - _Good_: An eval that provides a small, functional React component and asks + the agent to add a specific feature, requiring it to read the file, + understand the context, and write the correct changes. + - _Bad_: An eval that simply asks the agent a trivia question or asks it to + write a generic script without providing any local workspace context. +- **Maintainable Size**: Evals should be small enough to reason about and + maintain. We probably can't check in an entire repo as a test case, though + over time we will want these evals to mature into more and more realistic + scenarios. + - _Good_: A test setup with 2-3 files (e.g., a source file, a config file, and + a test file) that isolates the specific behavior being evaluated. + - _Bad_: A test setup containing dozens of files from a complex framework + where the setup logic itself is prone to breaking. +- **Unambiguous and Reliable Assertions**: Assertions must be clear and specific + to ensure the test passes for the right reason. + - _Good_: Checking that a modified file contains a specific AST node or exact + string, or verifying that a tool was called with with the right parameters. + - _Bad_: Only checking for a tool call, which could happen for an unrelated + reason. Expecting specific LLM output. +- **Fail First**: Have tests that failed before your prompt or tool change. We + want to be sure the test fails before your "fix". It's pretty easy to + accidentally create a passing test that asserts behaviors we get for free. In + general, every eval should be accompanied by prompt change, and most prompt + changes should be accompanied by an eval. + - _Good_: Observing a failure, writing an eval that reliably reproduces the + failure, modifying the prompt/tool, and then verifying the eval passes. + - _Bad_: Writing an eval that passes on the first run and assuming your new + prompt change was responsible. +- **Less is More**: Prefer fewer, more realistic tests that assert the major + paths vs. more tests that are more unit-test like. These are evals, so the + value is in testing how the agent works in a semi-realistic scenario. + ## Creating an Evaluation Evaluations are located in the `evals` directory. Each evaluation is a Vitest @@ -46,18 +89,20 @@ two arguments: #### Policies -Policies control how strictly a test is validated. Tests should generally use -the ALWAYS_PASSES policy to offer the strictest guarantees. - -USUALLY_PASSES exists to enable assertion of less consistent or aspirational -behaviors. +Policies control how strictly a test is validated. - `ALWAYS_PASSES`: Tests expected to pass 100% of the time. These are typically - trivial and test basic functionality. These run in every CI. + trivial and test basic functionality. These run in every CI and can block PRs + on failure. - `USUALLY_PASSES`: Tests expected to pass most of the time but may have some flakiness due to non-deterministic behaviors. These are run nightly and used to track the health of the product from build to build. +**All new behavioral evaluations must be created with the `USUALLY_PASSES` +policy.** A subset that prove to be highly stable over time may be promoted to +`ALWAYS_PASSES`. For more information, see +[Test promotion process](#test-promotion-process). + #### `EvalCase` Properties - `name`: The name of the evaluation case. @@ -76,7 +121,8 @@ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('my_feature', () => { - evalTest('ALWAYS_PASSES', { + // New tests MUST start as USUALLY_PASSES and be promoted via /promote-behavioral-eval + evalTest('USUALLY_PASSES', { name: 'should do something', prompt: 'do it', assert: async (rig, result) => { @@ -114,6 +160,39 @@ npm run test:all_evals This command sets the `RUN_EVALS` environment variable to `1`, which enables the `USUALLY_PASSES` tests. +## Ensuring Eval is Stable Prior to Check-in + +The +[Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml) +run is considered to be the source of truth for the quality of an eval test. +Each run of it executes a test 3 times in a row, for each supported model. The +result is then scored 0%, 33%, 66%, or 100% respectively, to indicate how many +of the individual executions passed. + +Googlers can schedule a manual run against their branch by clicking the link +above. + +Tests should score at least 66% with key models including Gemini 3.1 pro, Gemini +3.0 pro, and Gemini 3 flash prior to check in and they must pass 100% of the +time before they are promoted. + +## Test promotion process + +To maintain a stable and reliable CI, all new behavioral evaluations follow a +mandatory deflaking process. + +1. **Incubation**: You must create all new tests with the `USUALLY_PASSES` + policy. This lets them be monitored in the nightly runs without blocking PRs. +2. **Monitoring**: The test must complete at least 10 nightly runs across all + supported models. +3. **Promotion**: Promotion to `ALWAYS_PASSES` happens exclusively through the + `/promote-behavioral-eval` slash command. This command verifies the 100% + success rate requirement is met across many runs before updating the test + policy. + +This promotion process is essential for preventing the introduction of flaky +evaluations into the CI. + ## Reporting Results for evaluations are available on GitHub Actions: @@ -135,7 +214,7 @@ aggregated into a **Nightly Summary** attached to the workflow run. - **Pass Rate (%)**: Each cell represents the percentage of successful runs for a specific test in that workflow instance. -- **History**: The table shows the pass rates for the last 10 nightly runs, +- **History**: The table shows the pass rates for the last 7 nightly runs, allowing you to identify if a model's behavior is trending towards instability. - **Total Pass Rate**: An aggregate metric of all evaluations run in that batch. @@ -184,8 +263,35 @@ gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/ When investigating failures manually, you can also enable verbose agent logs by setting the `GEMINI_DEBUG_LOG_FILE` environment variable. +### Best practices + It's highly recommended to manually review and/or ask the agent to iterate on any prompt changes, even if they pass all evals. The prompt should prefer positive traits ('do X') and resort to negative traits ('do not do X') only when unable to accomplish the goal with positive traits. Gemini is quite good at instrospecting on its prompt when asked the right questions. + +## Promoting evaluations + +Evaluations must be promoted from `USUALLY_PASSES` to `ALWAYS_PASSES` +exclusively using the `/promote-behavioral-eval` slash command. Manual promotion +is not allowed to ensure that the 100% success rate requirement is empirically +met. + +### `/promote-behavioral-eval` + +This command automates the promotion of stable tests by: + +1. **Investigating**: Analyzing the results of the last 7 nightly runs on the + `main` branch using the `gh` CLI. +2. **Criteria Check**: Identifying tests that have passed 100% of the time for + ALL enabled models across the entire 7-run history. +3. **Promotion**: Updating the test file's policy from `USUALLY_PASSES` to + `ALWAYS_PASSES`. +4. **Verification**: Running the promoted test locally to ensure correctness. + +To run it: + +```bash +gemini /promote-behavioral-eval +``` diff --git a/evals/answer-vs-act.eval.ts b/evals/answer-vs-act.eval.ts index 7ee273fc31..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/hooks-agent-flow.test.ts b/integration-tests/hooks-agent-flow.test.ts index 757c692366..949770308b 100644 --- a/integration-tests/hooks-agent-flow.test.ts +++ b/integration-tests/hooks-agent-flow.test.ts @@ -165,14 +165,15 @@ describe('Hooks Agent Flow', () => { // BeforeModel hook to track message counts across LLM calls const messageCountFile = join(rig.testDir!, 'message-counts.json'); + const escapedPath = JSON.stringify(messageCountFile); const beforeModelScript = ` const fs = require('fs'); const input = JSON.parse(fs.readFileSync(0, 'utf-8')); const messageCount = input.llm_request?.contents?.length || 0; let counts = []; - try { counts = JSON.parse(fs.readFileSync(${JSON.stringify(messageCountFile)}, 'utf-8')); } catch (e) {} + try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {} counts.push(messageCount); - fs.writeFileSync(${JSON.stringify(messageCountFile)}, JSON.stringify(counts)); + fs.writeFileSync(${escapedPath}, JSON.stringify(counts)); console.log(JSON.stringify({ decision: 'allow' })); `; const beforeModelScriptPath = rig.createScript( diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index 215cf21226..473b966d5a 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -81,7 +81,9 @@ describe('JSON output', () => { const message = (thrown as Error).message; // Use a regex to find the first complete JSON object in the string - const jsonMatch = message.match(/{[\s\S]*}/); + // We expect the JSON to start with a quote (e.g. {"error": ...}) to avoid + // matching random error objects printed to stderr (like ENOENT). + const jsonMatch = message.match(/{\s*"[\s\S]*}/); // Fail if no JSON-like text was found expect( diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index 784bb890a0..8709aac189 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, checkModelOutputContent } from './test-helper.js'; +import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js'; describe('Plan Mode', () => { let rig: TestRig; @@ -62,50 +64,98 @@ describe('Plan Mode', () => { }); }); - it('should allow write_file only in the plans directory in plan mode', async () => { - await rig.setup( - 'should allow write_file only in the plans directory in plan mode', - { - settings: { - experimental: { plan: true }, - tools: { - core: ['write_file', 'read_file', 'list_directory'], - allowed: ['write_file'], + it('should allow write_file to the plans directory in plan mode', async () => { + const plansDir = '.gemini/tmp/foo/123/plans'; + const testName = + 'should allow write_file to the plans directory in plan mode'; + + await rig.setup(testName, { + settings: { + experimental: { plan: true }, + tools: { + core: ['write_file', 'read_file', 'list_directory'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, }, - general: { defaultApprovalMode: 'plan' }, }, }, - ); - - // We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory. - // Verify that write_file outside of plan directory fails - await rig.run({ - approvalMode: 'plan', - stdin: - 'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory', }); - const toolLogs = rig.readToolLogs(); - const writeLogs = toolLogs.filter( - (l) => l.toolRequest.name === 'write_file', + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); - const planWrite = writeLogs.find( + const run = await rig.runInteractive({ + approvalMode: 'plan', + }); + + await run.type('Create a file called plan.md in the plans directory.'); + await run.type('\r'); + + await rig.expectToolCallSuccess(['write_file'], 30000, (args) => + args.includes('plan.md'), + ); + + const toolLogs = rig.readToolLogs(); + const planWrite = toolLogs.find( (l) => + l.toolRequest.name === 'write_file' && l.toolRequest.args.includes('plans') && l.toolRequest.args.includes('plan.md'), ); + expect(planWrite?.toolRequest.success).toBe(true); + }); - const blockedWrite = writeLogs.find((l) => - l.toolRequest.args.includes('hello.txt'), + it('should deny write_file to non-plans directory in plan mode', async () => { + const plansDir = '.gemini/tmp/foo/123/plans'; + const testName = + 'should deny write_file to non-plans directory in plan mode'; + + await rig.setup(testName, { + settings: { + experimental: { plan: true }, + tools: { + core: ['write_file', 'read_file', 'list_directory'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, + }, + }, + }, + }); + + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); - // Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't - if (blockedWrite) { - expect(blockedWrite?.toolRequest.success).toBe(false); - } + const run = await rig.runInteractive({ + approvalMode: 'plan', + }); - expect(planWrite?.toolRequest.success).toBe(true); + await run.type('Create a file called hello.txt in the current directory.'); + await run.type('\r'); + + const toolLogs = rig.readToolLogs(); + const writeLog = toolLogs.find( + (l) => + l.toolRequest.name === 'write_file' && + l.toolRequest.args.includes('hello.txt'), + ); + + // In Plan Mode, writes outside the plans directory should be blocked. + // Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail. + if (writeLog) { + expect(writeLog.toolRequest.success).toBe(false); + } }); it('should be able to enter plan mode from default mode', async () => { @@ -119,6 +169,12 @@ describe('Plan Mode', () => { }, }); + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), + ); + // Start in default mode and ask to enter plan mode. await rig.run({ approvalMode: 'default', @@ -126,10 +182,7 @@ describe('Plan Mode', () => { 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.', }); - const enterPlanCallFound = await rig.waitForToolCall( - 'enter_plan_mode', - 10000, - ); + const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode'); expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe( true, ); diff --git a/integration-tests/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/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts index 0587bb30df..8ae72fed84 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/run_shell_command.test.ts @@ -18,6 +18,7 @@ const { shell } = getShellConfiguration(); function getLineCountCommand(): { command: string; tool: string } { switch (shell) { case 'powershell': + return { command: `Measure-Object -Line`, tool: 'Measure-Object' }; case 'cmd': return { command: `find /c /v`, tool: 'find' }; case 'bash': @@ -238,8 +239,12 @@ describe('run_shell_command', () => { }); it('should succeed in yolo mode', async () => { + const isWindows = process.platform === 'win32'; await rig.setup('should succeed in yolo mode', { - settings: { tools: { core: ['run_shell_command'] } }, + settings: { + tools: { core: ['run_shell_command'] }, + shell: isWindows ? { enableInteractiveShell: false } : undefined, + }, }); const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); diff --git a/integration-tests/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/package-lock.json b/package-lock.json index 5f0c5f058d..8f7ed6be5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "workspaces": [ "packages/*" ], @@ -2292,7 +2292,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2473,7 +2472,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2523,7 +2521,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2898,7 +2895,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2932,7 +2928,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2987,7 +2982,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4184,7 +4178,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4458,7 +4451,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5306,7 +5298,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5473,6 +5464,13 @@ "node": ">=8" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT", + "peer": true + }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -6572,6 +6570,10 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, "engines": { "node": ">=18" }, @@ -7860,7 +7862,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8493,7 +8494,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8550,6 +8550,36 @@ "express": ">= 4.11" } }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -8801,11 +8831,34 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "peer": true + }, + "node_modules/finalhandler/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" } }, "node_modules/find-up": { @@ -9788,7 +9841,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -10068,7 +10120,6 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13718,7 +13769,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13729,7 +13779,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15689,7 +15738,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15913,8 +15961,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -15922,7 +15969,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16082,7 +16128,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16241,6 +16286,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -16291,7 +16346,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16405,7 +16459,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16418,7 +16471,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17063,7 +17115,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17079,7 +17130,7 @@ }, "packages/a2a-server": { "name": "@google/gemini-cli-a2a-server", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "dependencies": { "@a2a-js/sdk": "^0.3.8", "@google-cloud/storage": "^7.16.0", @@ -17137,7 +17188,7 @@ }, "packages/cli": { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", @@ -17220,7 +17271,7 @@ }, "packages/core": { "name": "@google/gemini-cli-core", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "^0.3.8", @@ -17463,7 +17514,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17486,7 +17536,7 @@ }, "packages/devtools": { "name": "@google/gemini-cli-devtools", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "license": "Apache-2.0", "dependencies": { "ws": "^8.16.0" @@ -17501,7 +17551,7 @@ }, "packages/sdk": { "name": "@google/gemini-cli-sdk", - "version": "0.29.0-nightly.20260203.71f46f116", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17518,7 +17568,7 @@ }, "packages/test-utils": { "name": "@google/gemini-cli-test-utils", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "license": "Apache-2.0", "dependencies": { "@google/gemini-cli-core": "file:../core", @@ -17535,7 +17585,7 @@ }, "packages/vscode-ide-companion": { "name": "gemini-cli-vscode-ide-companion", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0", diff --git a/package.json b/package.json index a7ee06676e..b1053f5b8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "engines": { "node": ">=20.0.0" }, @@ -14,7 +14,7 @@ "url": "git+https://github.com/google-gemini/gemini-cli.git" }, "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0" }, "scripts": { "start": "cross-env NODE_ENV=development node scripts/start.js", @@ -37,10 +37,12 @@ "build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:packages": "npm run build --workspaces", "build:sandbox": "node scripts/build_sandbox.js", + "build:binary": "node scripts/build_binary.js", "bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js", - "test": "npm run test --workspaces --if-present", - "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts", + "test": "npm run test --workspaces --if-present && npm run test:sea-launch", + "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", + "test:sea-launch": "vitest run sea/sea-launch.test.js", "test:always_passing_evals": "vitest run --config evals/vitest.config.ts", "test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts", "test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none", diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json index bc85e51bc6..0428a84311 100644 --- a/packages/a2a-server/package.json +++ b/packages/a2a-server/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli-a2a-server", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "description": "Gemini CLI A2A Server", "repository": { "type": "git", 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..c969e601c3 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -27,11 +27,15 @@ import { type ToolCallConfirmationDetails, type Config, type UserTierId, - type AnsiOutput, + type ToolLiveOutput, + isSubagentProgress, 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 +48,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'; @@ -333,11 +337,13 @@ export class Task { private _schedulerOutputUpdate( toolCallId: string, - outputChunk: string | AnsiOutput, + outputChunk: ToolLiveOutput, ): void { let outputAsText: string; if (typeof outputChunk === 'string') { outputAsText = outputChunk; + } else if (isSubagentProgress(outputChunk)) { + outputAsText = JSON.stringify(outputChunk); } else { outputAsText = outputChunk .map((line) => line.map((token) => token.text).join('')) 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.test.ts b/packages/a2a-server/src/config/config.test.ts index e68ebc4431..c676e46289 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -28,6 +28,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { const mockConfig = { ...params, initialize: vi.fn(), + waitForMcpInit: vi.fn(), refreshAuth: vi.fn(), getExperiments: vi.fn().mockReturnValue({ flags: { @@ -94,6 +95,7 @@ describe('loadConfig', () => { const mockConfig = { ...(params as object), initialize: vi.fn(), + waitForMcpInit: vi.fn(), refreshAuth: vi.fn(), getExperiments: vi.fn().mockReturnValue({ flags: { diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 6a27bca4d5..f3100bce4d 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'; @@ -168,6 +166,8 @@ export async function loadConfig( // Needed to initialize ToolRegistry, and git checkpointing if enabled await config.initialize(); + + await config.waitForMcpInit(); startupProfiler.flush(config); await refreshAuthentication(config, adcFilePath, 'Config'); 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/http/app.ts b/packages/a2a-server/src/http/app.ts index 161139279b..35ca48949f 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import express from 'express'; +import express, { type Request } from 'express'; import type { AgentCard, Message } from '@a2a-js/sdk'; import { @@ -13,8 +13,9 @@ import { InMemoryTaskStore, DefaultExecutionEventBus, type AgentExecutionEvent, + UnauthenticatedUser, } from '@a2a-js/sdk/server'; -import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components +import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import type { AgentSettings } from '../types.js'; @@ -55,8 +56,17 @@ const coderAgentCard: AgentCard = { pushNotifications: false, stateTransitionHistory: true, }, - securitySchemes: undefined, - security: undefined, + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + basicAuth: { + type: 'http', + scheme: 'basic', + }, + }, + security: [{ bearerAuth: [] }, { basicAuth: [] }], defaultInputModes: ['text'], defaultOutputModes: ['text'], skills: [ @@ -81,6 +91,35 @@ export function updateCoderAgentCardUrl(port: number) { coderAgentCard.url = `http://localhost:${port}/`; } +const customUserBuilder: UserBuilder = async (req: Request) => { + const auth = req.headers['authorization']; + if (auth) { + const scheme = auth.split(' ')[0]; + logger.info( + `[customUserBuilder] Received Authorization header with scheme: ${scheme}`, + ); + } + if (!auth) return new UnauthenticatedUser(); + + // 1. Bearer Auth + if (auth.startsWith('Bearer ')) { + const token = auth.substring(7); + if (token === 'valid-token') { + return { userName: 'bearer-user', isAuthenticated: true }; + } + } + + // 2. Basic Auth + if (auth.startsWith('Basic ')) { + const credentials = Buffer.from(auth.substring(6), 'base64').toString(); + if (credentials === 'admin:password') { + return { userName: 'basic-user', isAuthenticated: true }; + } + } + + return new UnauthenticatedUser(); +}; + async function handleExecuteCommand( req: express.Request, res: express.Response, @@ -204,7 +243,7 @@ export async function createApp() { requestStorage.run({ req }, next); }); - const appBuilder = new A2AExpressApp(requestHandler); + const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder); expressApp = appBuilder.setupRoutes(expressApp, ''); expressApp.use(express.json()); diff --git a/packages/a2a-server/src/persistence/gcs.test.ts b/packages/a2a-server/src/persistence/gcs.test.ts index 43563448e5..353a8312d5 100644 --- a/packages/a2a-server/src/persistence/gcs.test.ts +++ b/packages/a2a-server/src/persistence/gcs.test.ts @@ -11,8 +11,16 @@ import { gzipSync, gunzipSync } from 'node:zlib'; import { v4 as uuidv4 } from 'uuid'; import type { Task as SDKTask } from '@a2a-js/sdk'; import type { TaskStore } from '@a2a-js/sdk/server'; -import type { Mocked, MockedClass, Mock } from 'vitest'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + describe, + it, + expect, + beforeEach, + vi, + type Mocked, + type MockedClass, + type Mock, +} from 'vitest'; import { GCSTaskStore, NoOpTaskStore } from './gcs.js'; import { logger } from '../utils/logger.js'; diff --git a/packages/a2a-server/src/utils/executor_utils.ts b/packages/a2a-server/src/utils/executor_utils.ts index b595a6905b..cf635f8822 100644 --- a/packages/a2a-server/src/utils/executor_utils.ts +++ b/packages/a2a-server/src/utils/executor_utils.ts @@ -8,8 +8,7 @@ import type { Message } from '@a2a-js/sdk'; import type { ExecutionEventBus } from '@a2a-js/sdk/server'; import { v4 as uuidv4 } from 'uuid'; -import { CoderAgentEvent } from '../types.js'; -import type { StateChange } from '../types.js'; +import { CoderAgentEvent, type StateChange } from '../types.js'; export async function pushTaskStateFailed( error: unknown, diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 9cb0657c7a..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/package.json b/packages/cli/package.json index 5ad1e37d9d..f4fd2f7bd1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@google/gemini-cli", - "version": "0.30.0-nightly.20260210.a2174751d", + "version": "0.33.0-nightly.20260228.1ca5c05d0", "description": "Gemini CLI", "license": "Apache-2.0", "repository": { @@ -26,7 +26,7 @@ "dist" ], "config": { - "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d" + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0" }, "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", 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..b22b7412cc 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -19,6 +19,8 @@ import { debugLogger, ApprovalMode, type MCPServerConfig, + type GeminiCLIExtension, + Storage, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import { @@ -2765,6 +2767,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; @@ -3464,4 +3526,101 @@ describe('loadCliConfig mcpEnabled', () => { expect(config.getAllowedMcpServers()).toEqual(['serverA']); expect(config.getBlockedMcpServers()).toEqual(['serverB']); }); + + describe('extension plan settings', () => { + beforeEach(() => { + vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue( + '/mock/home/user/.gemini/tmp/test-project', + ); + }); + + it('should use plan directory from active extension when user has not specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from active extension when user has specified one', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + general: { + plan: { directory: 'user-plans-dir' }, + }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: true, + plan: { directory: 'ext-plans-dir' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).toContain('user-plans-dir'); + expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir'); + }); + + it('should NOT use plan directory from inactive extension', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + { + name: 'ext-plan', + isActive: false, + plan: { directory: 'ext-plans-dir-inactive' }, + } as unknown as GeminiCLIExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.storage.getPlansDir()).not.toContain( + 'ext-plans-dir-inactive', + ); + }); + + it('should use default path if neither user nor extension settings provide a plan directory', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + experimental: { plan: true }, + }); + const argv = await parseArguments(settings); + + // No extensions providing plan directory + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + + const config = await loadCliConfig(settings, 'test-session', argv); + // Should return the default managed temp directory path + expect(config.storage.getPlansDir()).toBe( + path.join( + '/mock', + 'home', + 'user', + '.gemini', + 'tmp', + 'test-project', + 'test-session', + 'plans', + ), + ); + }); + }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6a4bd09470..b478d67478 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -511,6 +511,10 @@ export async function loadCliConfig( }); await extensionManager.loadExtensions(); + const extensionPlanSettings = extensionManager + .getExtensions() + .find((ext) => ext.isActive && ext.plan?.directory)?.plan; + const experimentalJitContext = settings.experimental?.jitContext ?? false; let memoryContent: string | HierarchicalMemory = ''; @@ -827,7 +831,9 @@ export async function loadCliConfig( enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, directWebFetch: settings.experimental?.directWebFetch, - planSettings: settings.general?.plan, + planSettings: settings.general?.plan?.directory + ? settings.general.plan + : (extensionPlanSettings ?? settings.general?.plan), enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, @@ -843,6 +849,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 +863,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..a9fce44635 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,9 @@ Would you like to attempt to install via "git clone" instead?`, skills, agents: agentLoadResult.agents, themes: config.themes, + rules, + checkers, + plan: config.plan, }; } catch (e) { debugLogger.error( 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/extension.ts b/packages/cli/src/config/extension.ts index 815cf23ece..04a7b885ca 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -33,6 +33,15 @@ export interface ExtensionConfig { * These themes will be registered when the extension is activated. */ themes?: CustomTheme[]; + /** + * Planning features configuration contributed by this extension. + */ + plan?: { + /** + * The directory where planning artifacts are stored. + */ + directory?: string; + }; } export interface ExtensionUpdateInfo { 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/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index e1b7305772..57430becae 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -102,7 +102,9 @@ export async function loadSandboxConfig( const packageJson = await getPackageJson(__dirname); const image = - process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri; + process.env['GEMINI_SANDBOX_IMAGE'] ?? + process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ?? + packageJson?.config?.sandboxImageUri; return command && image ? { command, image } : undefined; } diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 657968a3b6..4e9faf5767 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -185,9 +185,6 @@ export interface SessionRetentionSettings { /** Minimum retention period (safety limit, defaults to "1d") */ minRetention?: string; - - /** INTERNAL: Whether the user has acknowledged the session retention warning */ - warningAcknowledged?: boolean; } export interface SettingsError { 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..660866c0e3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -117,6 +117,10 @@ export interface SettingDefinition { * For map-like objects without explicit `properties`, describes the shape of the values. */ additionalProperties?: SettingCollectionDefinition; + /** + * Optional unit to display after the value (e.g. '%'). + */ + unit?: string; /** * Optional reference identifier for generators that emit a `$ref`. */ @@ -339,7 +343,7 @@ const SETTINGS_SCHEMA = { label: 'Enable Session Cleanup', category: 'General', requiresRestart: false, - default: false, + default: true as boolean, description: 'Enable automatic session cleanup', showInDialog: true, }, @@ -348,7 +352,7 @@ const SETTINGS_SCHEMA = { label: 'Keep chat history', category: 'General', requiresRestart: false, - default: undefined as string | undefined, + default: '30d' as string, description: 'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")', showInDialog: true, @@ -372,16 +376,6 @@ const SETTINGS_SCHEMA = { description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`, showInDialog: false, }, - warningAcknowledged: { - type: 'boolean', - label: 'Warning Acknowledged', - category: 'General', - requiresRestart: false, - default: false, - showInDialog: false, - description: - 'INTERNAL: Whether the user has acknowledged the session retention warning', - }, }, description: 'Settings for automatic session cleanup.', }, @@ -605,7 +599,7 @@ const SETTINGS_SCHEMA = { category: 'UI', requiresRestart: false, default: true, - description: 'Hides the context window remaining percentage.', + description: 'Hides the context window usage percentage.', showInDialog: true, }, }, @@ -719,6 +713,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 +836,36 @@ const SETTINGS_SCHEMA = { ref: 'TelemetrySettings', }, + billing: { + type: 'object', + label: 'Billing', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Billing and AI credits settings.', + showInDialog: false, + properties: { + overageStrategy: { + type: 'enum', + label: 'Overage Strategy', + category: 'Advanced', + requiresRestart: false, + default: 'ask', + description: oneLine` + How to handle quota exhaustion when AI credits are available. + 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + `, + showInDialog: true, + options: [ + { value: 'ask', label: 'Ask each time' }, + { value: 'always', label: 'Always use credits' }, + { value: 'never', label: 'Never use credits' }, + ], + }, + }, + }, + model: { type: 'object', label: 'Model', @@ -879,13 +917,14 @@ const SETTINGS_SCHEMA = { }, compressionThreshold: { type: 'number', - label: 'Compression Threshold', + label: 'Context Compression Threshold', category: 'Model', requiresRestart: true, default: 0.5 as number, description: 'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).', showInDialog: true, + unit: '%', }, disableLoopDetection: { type: 'boolean', @@ -1787,6 +1826,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 +2622,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 +2636,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..88f9f404cd 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; @@ -241,7 +243,7 @@ export async function startInteractiveUI( - + 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..86c46e79e5 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -528,12 +528,13 @@ export const mockSettings = new LoadedSettings( // A minimal mock UIState to satisfy the context provider. // Tests that need specific UIState values should provide their own. const baseMockUiState = { + history: [], renderMarkdown: true, streamingState: StreamingState.Idle, terminalWidth: 100, terminalHeight: 40, currentModel: 'gemini-pro', - terminalBackgroundColor: 'black', + terminalBackgroundColor: 'black' as const, cleanUiDetailsVisible: false, allowPlanMode: true, activePtyId: undefined, @@ -547,6 +548,14 @@ const baseMockUiState = { }, hintMode: false, hintBuffer: '', + bannerData: { + defaultText: '', + warningText: '', + }, + bannerVisible: false, + nightly: false, + updateInfo: null, + pendingHistoryItems: [], }; export const mockAppState: AppState = { @@ -586,6 +595,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 +618,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 +710,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,10 +753,10 @@ export const renderWithProviders = ( const renderResult = render( - + - + { }); }); + 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..4f8d739340 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'; @@ -143,9 +146,7 @@ import { requestConsentInteractive } from '../config/extensions/consent.js'; import { useSessionBrowser } from './hooks/useSessionBrowser.js'; import { useSessionResume } from './hooks/useSessionResume.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; -import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; -import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; @@ -228,7 +229,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 +265,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 +287,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 +393,9 @@ export const AppContainer = (props: AppContainerProps) => { ? { remaining, limit, resetTime } : undefined; }); + const [paidTier, setPaidTier] = useState( + undefined, + ); const [isConfigInitialized, setConfigInitialized] = useState(false); @@ -567,7 +549,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 +674,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 +698,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 +749,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 +763,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 +829,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 +1256,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 +1328,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 +1344,7 @@ Logging in with Google... Restarting Gemini CLI to continue. clearConsoleMessagesState, refreshStatic, reset, - setShowIsExpandableHint, + triggerExpandHint, ]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); @@ -1548,28 +1547,6 @@ Logging in with Google... Restarting Gemini CLI to continue. useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); - const handleAutoEnableRetention = useCallback(() => { - const userSettings = settings.forScope(SettingScope.User).settings; - const currentRetention = userSettings.general?.sessionRetention ?? {}; - - settings.setValue(SettingScope.User, 'general.sessionRetention', { - ...currentRetention, - enabled: true, - maxAge: '30d', - warningAcknowledged: true, - }); - }, [settings]); - - const { - shouldShowWarning: shouldShowRetentionWarning, - checkComplete: retentionCheckComplete, - sessionsToDeleteCount, - } = useSessionRetentionCheck( - config, - settings.merged, - handleAutoEnableRetention, - ); - const tabFocusTimeoutRef = useRef(null); useEffect(() => { @@ -1632,17 +1609,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 +1666,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 +1715,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 +1764,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 +1869,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( () => { @@ -2033,7 +1992,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const nightly = props.version.includes('nightly'); const dialogsVisible = - (shouldShowRetentionWarning && retentionCheckComplete) || + shouldShowIdePrompt || shouldShowIdePrompt || isFolderTrustDialogOpen || isPolicyUpdateDialogOpen || @@ -2056,6 +2015,8 @@ Logging in with Google... Restarting Gemini CLI to continue. showIdeRestartPrompt || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || isSessionBrowserOpen || authState === AuthState.AwaitingApiKeyInput || !!newAgents; @@ -2083,6 +2044,8 @@ Logging in with Google... Restarting Gemini CLI to continue. hasLoopDetectionConfirmationRequest || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || !!customDialog; const allowPlanMode = @@ -2216,13 +2179,12 @@ Logging in with Google... Restarting Gemini CLI to continue. history: historyManager.history, historyManager, isThemeDialogOpen, - shouldShowRetentionWarning: - shouldShowRetentionWarning && retentionCheckComplete, - sessionsToDeleteCount: sessionsToDeleteCount ?? 0, + themeError, isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, apiKeyDefaultValue, @@ -2293,6 +2255,9 @@ Logging in with Google... Restarting Gemini CLI to continue. stats: quotaStats, proQuotaRequest, validationRequest, + // G1 AI Credits dialog state + overageMenuRequest, + emptyWalletRequest, }, contextFileNames, errorCount, @@ -2344,13 +2309,12 @@ Logging in with Google... Restarting Gemini CLI to continue. }), [ isThemeDialogOpen, - shouldShowRetentionWarning, - retentionCheckComplete, - sessionsToDeleteCount, + themeError, isAuthenticating, isConfigInitialized, authError, + accountSuspensionInfo, isAuthDialogOpen, editorError, isEditorDialogOpen, @@ -2417,6 +2381,8 @@ Logging in with Google... Restarting Gemini CLI to continue. quotaStats, proQuotaRequest, validationRequest, + overageMenuRequest, + emptyWalletRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -2498,6 +2464,9 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + // G1 AI Credits handlers + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -2554,6 +2523,11 @@ Logging in with Google... Restarting Gemini CLI to continue. } setNewAgents(null); }, + getPreferredEditor, + clearAccountSuspension: () => { + setAccountSuspensionInfo(null); + setAuthState(AuthState.Updating); + }, }), [ handleThemeSelect, @@ -2583,6 +2557,8 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -2602,9 +2578,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/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 450da8362e..9e1d66df01 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -2,20 +2,20 @@ exports[`App > Snapshots > renders default layout correctly 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results + + + @@ -47,34 +47,31 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` "Notifications Footer - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results Composer " `; exports[`App > Snapshots > renders with dialogs visible 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + + + + @@ -110,20 +107,17 @@ DialogManager exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results HistoryItemDisplay ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Action Required │ @@ -146,6 +140,9 @@ HistoryItemDisplay + + + Notifications Composer " diff --git a/packages/cli/src/ui/auth/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/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index cc862b6c42..c873050490 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -21,6 +21,10 @@ import { ConfigExtensionDialog, type ConfigExtensionDialogProps, } from '../components/ConfigExtensionDialog.js'; +import { + ExtensionRegistryView, + type ExtensionRegistryViewProps, +} from '../components/views/ExtensionRegistryView.js'; import { type CommandContext, type SlashCommand } from './types.js'; import { @@ -39,6 +43,8 @@ import { } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; import { stat } from 'node:fs/promises'; +import { type RegistryExtension } from '../../config/extensionRegistryClient.js'; +import { waitFor } from '../../test-utils/async.js'; vi.mock('../../config/extension-manager.js', async (importOriginal) => { const actual = @@ -167,6 +173,7 @@ describe('extensionsCommand', () => { }, ui: { dispatchExtensionStateUpdate: mockDispatchExtensionState, + removeComponent: vi.fn(), }, }); }); @@ -429,6 +436,61 @@ describe('extensionsCommand', () => { throw new Error('Explore action not found'); } + it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = true; + + const result = await exploreAction(mockContext, ''); + + expect(result).toBeDefined(); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + expect(component.type).toBe(ExtensionRegistryView); + expect(component.props.extensionManager).toBe(mockExtensionLoader); + }); + + it('should handle onSelect and onClose in ExtensionRegistryView', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = true; + + const result = await exploreAction(mockContext, ''); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + + const extension = { + extensionName: 'test-ext', + url: 'https://github.com/test/ext.git', + } as RegistryExtension; + + vi.mocked(inferInstallMetadata).mockResolvedValue({ + source: extension.url, + type: 'git', + }); + mockInstallExtension.mockResolvedValue({ name: extension.url }); + + // Call onSelect + component.props.onSelect?.(extension); + + await waitFor(() => { + expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); + expect(mockInstallExtension).toHaveBeenCalledWith({ + source: extension.url, + type: 'git', + }); + }); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); + + // Call onClose + component.props.onClose?.(); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2); + }); + it("should add an info message and call 'open' in a non-sandbox environment", async () => { // Ensure no special environment variables that would affect behavior vi.stubEnv('NODE_ENV', ''); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 0a8a8d74e3..842a680a14 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -280,7 +280,9 @@ async function exploreAction( type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { onSelect: (extension) => { - debugLogger.debug(`Selected extension: ${extension.extensionName}`); + debugLogger.log(`Selected extension: ${extension.extensionName}`); + void installAction(context, extension.url); + context.ui.removeComponent(); }, onClose: () => context.ui.removeComponent(), extensionManager, diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index ed7f7bb747..8e5c54d17d 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -7,7 +7,6 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { hooksCommand } from './hooksCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { MessageType } from '../types.js'; import type { HookRegistryEntry } from '@google/gemini-cli-core'; import { HookType, HookEventName, ConfigSource } from '@google/gemini-cli-core'; import type { CommandContext } from './types.js'; @@ -127,13 +126,10 @@ describe('hooksCommand', () => { createMockHook('test-hook', HookEventName.BeforeTool, true), ]); - await hooksCommand.action(mockContext, ''); + const result = await hooksCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); }); @@ -161,7 +157,7 @@ describe('hooksCommand', () => { }); }); - it('should display panel even when hook system is not enabled', async () => { + it('should return custom_dialog even when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const panelCmd = hooksCommand.subCommands!.find( @@ -171,17 +167,13 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: [], - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); - it('should display panel when no hooks are configured', async () => { + it('should return custom_dialog when no hooks are configured', async () => { mockHookSystem.getAllHooks.mockReturnValue([]); (mockContext.services.settings.merged as Record)[ 'hooksConfig' @@ -194,17 +186,13 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: [], - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); - it('should display hooks list when hooks are configured', async () => { + it('should return custom_dialog when hooks are configured', async () => { const mockHooks: HookRegistryEntry[] = [ createMockHook('echo-test', HookEventName.BeforeTool, true), createMockHook('notify', HookEventName.AfterAgent, false), @@ -222,14 +210,10 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: mockHooks, - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 92fa72b235..bc51f42037 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -4,9 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand, CommandContext } from './types.js'; +import { createElement } from 'react'; +import type { + SlashCommand, + CommandContext, + OpenCustomDialogActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; -import { MessageType, type HistoryItemHooksList } from '../types.js'; import type { HookRegistryEntry, MessageActionReturn, @@ -15,13 +19,14 @@ import { getErrorMessage } from '@google/gemini-cli-core'; import { SettingScope, isLoadableSettingScope } from '../../config/settings.js'; import { enableHook, disableHook } from '../../utils/hookSettings.js'; import { renderHookActionFeedback } from '../../utils/hookUtils.js'; +import { HooksDialog } from '../components/HooksDialog.js'; /** - * Display a formatted list of hooks with their status + * Display a formatted list of hooks with their status in a dialog */ -async function panelAction( +function panelAction( context: CommandContext, -): Promise { +): MessageActionReturn | OpenCustomDialogActionReturn { const { config } = context.services; if (!config) { return { @@ -34,12 +39,13 @@ async function panelAction( const hookSystem = config.getHookSystem(); const allHooks = hookSystem?.getAllHooks() || []; - const hooksListItem: HistoryItemHooksList = { - type: MessageType.HOOKS_LIST, - hooks: allHooks, + return { + type: 'custom_dialog', + component: createElement(HooksDialog, { + hooks: allHooks, + onClose: () => context.ui.removeComponent(), + }), }; - - context.ui.addItem(hooksListItem); } /** @@ -343,6 +349,7 @@ const panelCommand: SlashCommand = { altNames: ['list', 'show'], description: 'Display all registered hooks with their status', kind: CommandKind.BUILT_IN, + autoExecute: true, action: panelAction, }; @@ -393,5 +400,5 @@ export const hooksCommand: SlashCommand = { enableAllCommand, disableAllCommand, ], - action: async (context: CommandContext) => panelCommand.action!(context, ''), + action: (context: CommandContext) => panelCommand.action!(context, ''), }; diff --git a/packages/cli/src/ui/commands/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/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx index 5b4eb1e912..4079c6df77 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx @@ -19,6 +19,7 @@ import { BaseSettingsDialog, type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; +import { getNestedValue, isRecord } from '../../utils/settingsUtils.js'; /** * Configuration field definition for agent settings @@ -111,32 +112,12 @@ interface AgentConfigDialogProps { onSave?: () => void; } -/** - * Get a nested value from an object using a path array - */ -function getNestedValue( - obj: Record | undefined, - path: string[], -): unknown { - if (!obj) return undefined; - let current: unknown = obj; - for (const key of path) { - if (current === null || current === undefined) return undefined; - if (typeof current !== 'object') return undefined; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current = (current as Record)[key]; - } - return current; -} - /** * Set a nested value in an object using a path array, creating intermediate objects as needed */ -function setNestedValue( - obj: Record, - path: string[], - value: unknown, -): Record { +function setNestedValue(obj: unknown, path: string[], value: unknown): unknown { + if (!isRecord(obj)) return obj; + const result = { ...obj }; let current = result; @@ -144,12 +125,17 @@ function setNestedValue( const key = path[i]; if (current[key] === undefined || current[key] === null) { current[key] = {}; - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current[key] = { ...(current[key] as Record) }; + } else if (isRecord(current[key])) { + current[key] = { ...current[key] }; + } + + const next = current[key]; + if (isRecord(next)) { + current = next; + } else { + // Cannot traverse further through non-objects + return result; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current = current[key] as Record; } const finalKey = path[path.length - 1]; @@ -267,11 +253,7 @@ export function AgentConfigDialog({ const items: SettingsDialogItem[] = useMemo( () => AGENT_CONFIG_FIELDS.map((field) => { - const currentValue = getNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, - field.path, - ); + const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; @@ -324,23 +306,18 @@ export function AgentConfigDialog({ const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); if (!field || field.type !== 'boolean') return; - const currentValue = getNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, - field.path, - ); + const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; const newValue = !effectiveValue; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, newValue, ) as AgentOverride; - setPendingOverride(newOverride); setModifiedFields((prev) => new Set(prev).add(key)); @@ -375,9 +352,9 @@ export function AgentConfigDialog({ } // Update pending override locally + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, parsed, ) as AgentOverride; @@ -398,9 +375,9 @@ export function AgentConfigDialog({ if (!field) return; // Remove the override (set to undefined) + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, undefined, ) as AgentOverride; diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 9bf821febc..ebcd4de973 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -213,6 +213,12 @@ describe('', () => { it('should NOT render Tips when tipsShown is 10 or more', async () => { const mockConfig = makeFakeConfig(); + const uiState = { + bannerData: { + defaultText: '', + warningText: '', + }, + }; persistentStateMock.setData({ tipsShown: 10 }); @@ -220,6 +226,7 @@ describe('', () => { , { config: mockConfig, + uiState, }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index ad5e2f67d2..b9601e772a 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -1,58 +1,113 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { Box } from 'ink'; -import { Header } from './Header.js'; -import { Tips } from './Tips.js'; +import { Box, Text } from 'ink'; import { UserIdentity } from './UserIdentity.js'; +import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { Banner } from './Banner.js'; import { useBanner } from '../hooks/useBanner.js'; import { useTips } from '../hooks/useTips.js'; +import { theme } from '../semantic-colors.js'; +import { ThemedGradient } from './ThemedGradient.js'; +import { CliSpinner } from './CliSpinner.js'; interface AppHeaderProps { version: string; showDetails?: boolean; } +const ICON = `▝▜▄ + ▝▜▄ + ▗▟▀ +▝▀ `; + export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); - const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState(); + const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState(); const { bannerText } = useBanner(bannerData); const { showTips } = useTips(); + const showHeader = !( + settings.merged.ui.hideBanner || config.getScreenReader() + ); + if (!showDetails) { return ( -
+ {showHeader && ( + + + {ICON} + + + + + Gemini CLI + + v{version} + + + + )} ); } return ( - {!(settings.merged.ui.hideBanner || config.getScreenReader()) && ( - <> -
- {bannerVisible && bannerText && ( - - )} - + {showHeader && ( + + + {ICON} + + + {/* Line 1: Gemini CLI vVersion [Updating] */} + + + Gemini CLI + + v{version} + {updateInfo && ( + + + Updating + + + )} + + + {/* Line 2: Blank */} + + + {/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */} + {settings.merged.ui.showUserIdentity !== false && ( + + )} + + )} - {settings.merged.ui.showUserIdentity !== false && ( - + + {bannerVisible && bannerText && ( + )} + {!(settings.merged.ui.hideTips || config.getScreenReader()) && showTips && } diff --git a/packages/cli/src/ui/components/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/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index ae272d6145..bcd5fd62b5 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { describe, it, expect, vi } from 'vitest'; @@ -17,18 +17,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -vi.mock('../../config/settings.js', () => ({ - DEFAULT_MODEL_CONFIGS: {}, - LoadedSettings: class { - constructor() { - // this.merged = {}; - } - }, -})); - describe('ContextUsageDisplay', () => { - it('renders correct percentage left', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders correct percentage used', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('50% context left'); + expect(output).toContain('50% context used'); unmount(); }); - it('renders short label when terminal width is small', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders correctly when usage is 0%', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('0% context used'); + unmount(); + }); + + it('renders abbreviated label when terminal width is small', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { width: 80 }, ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('80%'); - expect(output).not.toContain('context left'); + expect(output).toContain('20%'); + expect(output).not.toContain('context used'); unmount(); }); - it('renders 0% when full', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + it('renders 80% correctly', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('80% context used'); + unmount(); + }); + + it('renders 100% when full', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( { ); await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('0% context left'); + expect(output).toContain('100% context used'); unmount(); }); }); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index 1c1d24cc2d..66cb8ed234 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -7,6 +7,11 @@ import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { getContextUsagePercentage } from '../utils/contextUsage.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { + MIN_TERMINAL_WIDTH_FOR_FULL_LABEL, + DEFAULT_COMPRESSION_THRESHOLD, +} from '../constants.js'; export const ContextUsageDisplay = ({ promptTokenCount, @@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({ terminalWidth, }: { promptTokenCount: number; - model: string; + model: string | undefined; terminalWidth: number; }) => { + const settings = useSettings(); const percentage = getContextUsagePercentage(promptTokenCount, model); - const percentageLeft = ((1 - percentage) * 100).toFixed(0); + const percentageUsed = (percentage * 100).toFixed(0); - const label = terminalWidth < 100 ? '%' : '% context left'; + const threshold = + settings.merged.model?.compressionThreshold ?? + DEFAULT_COMPRESSION_THRESHOLD; + + let textColor = theme.text.secondary; + if (percentage >= 1.0) { + textColor = theme.status.error; + } else if (percentage >= threshold) { + textColor = theme.status.warning; + } + + const label = + terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used'; return ( - - {percentageLeft} + + {percentageUsed} {label} ); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index 108db073d5..65d54e50d6 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('shows the F12 hint even 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()).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/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..c86a4ba8d3 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'; @@ -34,9 +37,6 @@ import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { NewAgentsNotification } from './NewAgentsNotification.js'; import { AgentConfigDialog } from './AgentConfigDialog.js'; -import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; -import { useCallback } from 'react'; -import { SettingScope } from '../../config/settings.js'; import { PolicyUpdateDialog } from './PolicyUpdateDialog.js'; interface DialogManagerProps { @@ -59,56 +59,8 @@ export const DialogManager = ({ terminalHeight, staticExtraHeight, terminalWidth: uiTerminalWidth, - shouldShowRetentionWarning, - sessionsToDeleteCount, } = uiState; - const handleKeep120Days = useCallback(() => { - settings.setValue( - SettingScope.User, - 'general.sessionRetention.warningAcknowledged', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.enabled', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.maxAge', - '120d', - ); - }, [settings]); - - const handleKeep30Days = useCallback(() => { - settings.setValue( - SettingScope.User, - 'general.sessionRetention.warningAcknowledged', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.enabled', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.maxAge', - '30d', - ); - }, [settings]); - - if (shouldShowRetentionWarning && sessionsToDeleteCount !== undefined) { - return ( - - ); - } - if (uiState.adminSettingsChanged) { return ; } @@ -135,6 +87,7 @@ export const DialogManager = ({ isModelNotFoundError={ !!uiState.quota.proQuotaRequest.isModelNotFoundError } + authType={uiState.quota.proQuotaRequest.authType} onChoice={uiActions.handleProQuotaChoice} /> ); @@ -151,6 +104,28 @@ export const DialogManager = ({ /> ); } + if (uiState.quota.overageMenuRequest) { + return ( + + ); + } + if (uiState.quota.emptyWalletRequest) { + return ( + + ); + } if (uiState.shouldShowIdePrompt) { return ( uiActions.closeSettingsDialog()} onRestartRequest={async () => { await runExitCleanup(); process.exit(RELAUNCH_EXIT_CODE); }} availableTerminalHeight={terminalHeight - staticExtraHeight} - config={config} /> ); @@ -295,6 +268,21 @@ export const DialogManager = ({ ); } + if (uiState.accountSuspensionInfo) { + return ( + + { + process.exit(1); + }} + onChangeAuth={() => { + uiActions.clearAccountSuspension(); + }} + /> + + ); + } if (uiState.isAuthenticating) { return ( ); } + if (uiState.isAuthDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/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..2bf1f723a6 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -19,6 +19,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(); @@ -36,10 +40,6 @@ vi.mock('node:fs', async (importOriginal) => { ...actual, existsSync: vi.fn(), realpathSync: vi.fn((p) => p), - promises: { - ...actual.promises, - readFile: vi.fn(), - }, }; }); @@ -144,6 +144,7 @@ Implement a comprehensive authentication system with multiple providers. onApprove={onApprove} onFeedback={onFeedback} onCancel={onCancel} + getPreferredEditor={vi.fn()} width={80} availableHeight={24} />, @@ -153,6 +154,7 @@ Implement a comprehensive authentication system with multiple providers. getTargetDir: () => mockTargetDir, getIdeMode: () => false, isTrustedFolder: () => true, + getPreferredEditor: () => undefined, storage: { getPlansDir: () => mockPlansDir, }, @@ -160,6 +162,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 +421,7 @@ Implement a comprehensive authentication system with multiple providers. onApprove={onApprove} onFeedback={onFeedback} onCancel={onCancel} + getPreferredEditor={vi.fn()} width={80} availableHeight={24} /> @@ -435,6 +439,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 +540,29 @@ Implement a comprehensive authentication system with multiple providers. }); expect(onFeedback).not.toHaveBeenCalled(); }); + + it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => { + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + + await act(async () => { + vi.runAllTimers(); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('Add user authentication'); + }); + + // Press Ctrl+X + await act(async () => { + writeKey(stdin, '\x18'); // Ctrl+X + }); + + await waitFor(() => { + expect(onFeedback).toHaveBeenCalledWith( + 'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.', + ); + }); + }); }, ); }); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 8777136d86..39e1b8a155 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,40 @@ 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()); + + onFeedback( + 'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.', + ); + refresh(); + } catch (err) { + debugLogger.error('Failed to open plan in editor:', err); + } + }, [planPath, stdin, setRawMode, getPreferredEditor, refresh, onFeedback]); + + 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 +223,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..7187240249 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -8,9 +8,26 @@ 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'; +let mockIsDevelopment = false; + +vi.mock('../../utils/installationInfo.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + get isDevelopment() { + return mockIsDevelopment; + }, + }; +}); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); @@ -157,7 +174,7 @@ describe('