mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 08:40:58 -07:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 545a23f120 | |||
| 4a48d7cf93 | |||
| 31522045cd | |||
| 11951592aa | |||
| bc8ffa6631 | |||
| 86bd7dbd4f | |||
| eee95c509d | |||
| a1f2aacd1e | |||
| 979bbee485 | |||
| 7450c926d1 | |||
| be6723ebcc | |||
| af606aed9b | |||
| a37844e5a1 | |||
| 6f1a5bf81d | |||
| 9178b31629 | |||
| 19dc40825e | |||
| bc9b3052ee | |||
| 3b0649d408 | |||
| 7409ce5df6 | |||
| e3796d137a | |||
| ee68a10e9c | |||
| 28805a4b2d | |||
| fd72a8c40f | |||
| 63f7e30790 | |||
| e844d4f45f | |||
| 7a8d6f6095 | |||
| 95d79b7cbe | |||
| 601f0606da | |||
| ad6d3fd902 | |||
| 1f1cf756c8 | |||
| ec5836c4d6 | |||
| f062f56b43 | |||
| 61d92c4a21 | |||
| 1495294cc0 | |||
| d86b1f7b7e | |||
| 099fea2434 | |||
| 1d70aa5c1b | |||
| 6fb3b09003 | |||
| 30354892b3 | |||
| 8ec176e005 | |||
| 289769f544 | |||
| 4ffc349c18 | |||
| 83c6342e6e | |||
| fe975da91e | |||
| 00a739e84c | |||
| 2498114df6 | |||
| 8efae719ee | |||
| 9ca7300c90 | |||
| dc09b4988d | |||
| bce57ca1af | |||
| 6860556afe | |||
| 258643dec4 | |||
| 5d04a01b06 | |||
| 5b9ea35b63 | |||
| 4a6e3eb646 | |||
| e4c80e6382 | |||
| ee2c8eef19 | |||
| 1cae5ab158 | |||
| ee58e1e3c1 | |||
| e3b8490edf | |||
| 2566057e44 | |||
| a342c954e9 | |||
| d29383a132 | |||
| a3af4a8cae | |||
| 821355c429 | |||
| 3afc8f25e1 | |||
| d79478689f | |||
| 650980af37 | |||
| bd156e6832 | |||
| b987e1780d | |||
| 0012d95848 | |||
| fedc0c5d60 | |||
| 99e523a15f | |||
| a0b6602d09 | |||
| b39cefe14e | |||
| 94f4e5cc15 | |||
| d866e7e6e7 | |||
| ed02b94570 | |||
| aba8c5f662 | |||
| d1cde575d9 | |||
| 71f46f1160 | |||
| d63c34b6e1 | |||
| 7a6dfa3704 | |||
| 69f8273481 | |||
| 1fc59484b1 | |||
| 53027af94c | |||
| 69c0585ab2 | |||
| 3e954930f1 | |||
| 2cf3a14439 | |||
| 0f918f0cc8 | |||
| 75dbf9022c |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"experimental": {
|
||||
"toolOutputMasking": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: docs-changelog
|
||||
description: Provides a step-by-step procedure for generating Gemini CLI changelog files based on github release information.
|
||||
---
|
||||
|
||||
# Procedure: Updating Changelog for New Releases
|
||||
|
||||
The following instructions are run by Gemini CLI when processing new releases.
|
||||
|
||||
## Objective
|
||||
|
||||
To standardize the process of updating the Gemini CLI changelog files for a new
|
||||
release, ensuring accuracy, consistency, and adherence to project style
|
||||
guidelines.
|
||||
|
||||
## Release Types
|
||||
|
||||
This skill covers two types of releases:
|
||||
|
||||
* **Standard Releases:** Regular, versioned releases that are announced to all
|
||||
users. These updates modify `docs/changelogs/latest.md` and
|
||||
`docs/changelogs/index.md`.
|
||||
* **Preview Releases:** Pre-release versions for testing and feedback. These
|
||||
updates only modify `docs/changelogs/preview.md`.
|
||||
|
||||
Ignore all other releases, such as nightly releases.
|
||||
|
||||
### Expected Inputs
|
||||
|
||||
Regardless of the type of release, the following information is expected:
|
||||
|
||||
* **New version number:** The version number for the new release
|
||||
(e.g., `v0.27.0`).
|
||||
* **Release date:** The date of the new release (e.g., `2026-02-03`).
|
||||
* **Raw changelog data:** A list of all pull requests and changes
|
||||
included in the release, in the format `description by @author in
|
||||
#pr_number`.
|
||||
* **Previous version number:** The version number of the last release can be
|
||||
calculated by decreasing the minor version number by one and setting the
|
||||
patch or bug fix version number.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Identify the files to be modified:
|
||||
|
||||
For standard releases, update `docs/changelogs/latest.md` and
|
||||
`docs/changelogs/index.md`. For preview releases, update
|
||||
`docs/changelogs/preview.md`.
|
||||
|
||||
2. Activate the `docs-writer` skill.
|
||||
|
||||
### Analyze Raw Changelog Data
|
||||
|
||||
1. Review the complete list of changes. If it is a patch or a bug fix with few
|
||||
changes, skip to the "Update `docs/changelogs/latest.md` or
|
||||
`docs/changelogs/preview.md`" section.
|
||||
|
||||
2. Group related changes into high-level categories such as
|
||||
important features, "UI/UX Improvements", and "Bug Fixes". Use the existing
|
||||
announcements in `docs/changelogs/index.md` as an example.
|
||||
|
||||
### Create Highlight Summaries
|
||||
|
||||
Create two distinct versions of the release highlights.
|
||||
|
||||
**Important:** Carefully inspect highlights for "experimental" or
|
||||
"preview" features before public announcement, and do not include them.
|
||||
|
||||
#### Version 1: Comprehensive Highlights (for `latest.md` or `preview.md`)
|
||||
|
||||
Write a detailed summary for each category focusing on user-facing
|
||||
impact.
|
||||
|
||||
#### Version 2: Concise Highlights (for `index.md`)
|
||||
|
||||
Skip this step for preview releases.
|
||||
|
||||
Write concise summaries including the primary PR and author
|
||||
(e.g., `([#12345](link) by @author)`).
|
||||
|
||||
### Update `docs/changelogs/latest.md` or `docs/changelogs/preview.md`
|
||||
|
||||
1. Read current content and use `write_file` to replace it with the new
|
||||
version number, and date.
|
||||
|
||||
If it is a patch or bug fix with few changes, simply add these
|
||||
changes to the "What's Changed" list. Otherwise, replace comprehensive
|
||||
highlights, and the full "What's Changed" list.
|
||||
|
||||
2. For each item in the "What's Changed" list, keep usernames in plaintext, and
|
||||
add github links for each issue number. Example:
|
||||
|
||||
"- feat: implement /rewind command by @username in
|
||||
[#12345](https://github.com/google-gemini/gemini-cli/pull/12345)"
|
||||
|
||||
3. Skip entries by @gemini-cli-robot.
|
||||
|
||||
4. Do not add the "New Contributors" section.
|
||||
|
||||
5. Update the "Full changelog:" link by doing one of following:
|
||||
|
||||
If it is a patch or bug fix with few changes, retain the original link
|
||||
but replace the latter version with the new version. For example, if the
|
||||
patch is version is "v0.28.1", replace the latter version:
|
||||
"https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.0" with
|
||||
"https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.1".
|
||||
|
||||
Otherwise, for minor and major version changes, replace the link with the
|
||||
one included at the end of the changelog data.
|
||||
|
||||
6. Ensure lines are wrapped to 80 characters.
|
||||
|
||||
### Update `docs/changelogs/index.md`
|
||||
|
||||
Skip this step for patches, bug fixes, or preview releases.
|
||||
|
||||
Insert a new "Announcements" section for the new version directly
|
||||
above the previous version's section. Ensure lines are wrapped to
|
||||
80 characters.
|
||||
|
||||
### Finalize
|
||||
|
||||
Run `npm run format` to ensure consistency.
|
||||
@@ -2,8 +2,7 @@
|
||||
name: docs-writer
|
||||
description:
|
||||
Always use this skill when the task involves writing, reviewing, or editing
|
||||
documentation, specifically for any files in the `/docs` directory or any
|
||||
`.md` files in the repository.
|
||||
files in the `/docs` directory or any `.md` files in the repository.
|
||||
---
|
||||
|
||||
# `docs-writer` skill instructions
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# This workflow is triggered on every new release.
|
||||
# It uses Gemini to generate release notes and creates a PR with the changes.
|
||||
name: 'Generate Release Notes'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: ['created']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'New version (e.g., v1.2.3)'
|
||||
required: true
|
||||
type: 'string'
|
||||
body:
|
||||
description: 'Release notes body'
|
||||
required: true
|
||||
type: 'string'
|
||||
time:
|
||||
description: 'Release time'
|
||||
required: true
|
||||
type: 'string'
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@v4'
|
||||
with:
|
||||
# The user-level skills need to be available to the workflow
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@v4'
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: 'Get release information'
|
||||
id: 'release_info'
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version || github.event.release.tag_name }}"
|
||||
BODY="${{ github.event.inputs.body || github.event.release.body }}"
|
||||
TIME="${{ github.event.inputs.time || github.event.release.created_at }}"
|
||||
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "TIME=${TIME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Use a heredoc to preserve multiline release body
|
||||
echo 'RAW_CHANGELOG<<EOF' >> "$GITHUB_OUTPUT"
|
||||
echo "${BODY}" >> "$GITHUB_OUTPUT"
|
||||
echo 'EOF' >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
|
||||
- name: 'Generate Changelog with Gemini'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
with:
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
prompt: |
|
||||
Activate the 'docs-changelog' skill.
|
||||
|
||||
**Release Information:**
|
||||
- New Version: ${{ steps.release_info.outputs.VERSION }}
|
||||
- Release Date: ${{ steps.release_info.outputs.TIME }}
|
||||
- Raw Changelog Data: ${{ steps.release_info.outputs.RAW_CHANGELOG }}
|
||||
|
||||
Execute the release notes generation process using the information provided.
|
||||
|
||||
- name: 'Create Pull Request'
|
||||
uses: 'peter-evans/create-pull-request@v6'
|
||||
with:
|
||||
token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
commit-message: 'docs(changelog): update for ${{ steps.release_info.outputs.VERSION }}'
|
||||
title: 'Changelog for ${{ steps.release_info.outputs.VERSION }}'
|
||||
body: |
|
||||
This PR contains the auto-generated changelog for the ${{ steps.release_info.outputs.VERSION }} release.
|
||||
|
||||
Please review and merge.
|
||||
branch: 'changelog-${{ steps.release_info.outputs.VERSION }}'
|
||||
team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers'
|
||||
delete-branch: true
|
||||
@@ -28,59 +28,22 @@ jobs:
|
||||
permission: 'write'
|
||||
issue-type: 'pull-request'
|
||||
|
||||
- name: 'Get Commits'
|
||||
id: 'get_commits'
|
||||
- name: 'Get PR Status'
|
||||
id: 'pr_status'
|
||||
if: "startsWith(github.event.comment.body, '/patch')"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
COMMENT_BODY: '${{ github.event.comment.body }}'
|
||||
CURRENT_PR: '${{ github.event.issue.number }}'
|
||||
run: |
|
||||
# Find all PR numbers in the comment body (e.g., #123, owner/repo#456)
|
||||
# The regex handles both formats and extracts just the number
|
||||
comment_prs=$(echo "$COMMENT_BODY" | grep -oP '(?:\S+/)?\S+#\K\d+')
|
||||
|
||||
# Combine with the current PR number, ensuring no duplicates
|
||||
all_prs=$(echo -e "${CURRENT_PR}\n${comment_prs}" | sort -u)
|
||||
|
||||
echo "Found PRs to patch: ${all_prs}"
|
||||
|
||||
commit_shas=()
|
||||
unmerged_prs=()
|
||||
merged_prs=()
|
||||
|
||||
for pr in $all_prs; do
|
||||
echo "Checking status of PR #${pr}..."
|
||||
# Get PR status (state and merge commit)
|
||||
pr_status=$(gh pr view "$pr" --json mergeCommit,state)
|
||||
state=$(echo "$pr_status" | jq -r .state)
|
||||
|
||||
if [[ "$state" != "MERGED" ]]; then
|
||||
unmerged_prs+=("$pr")
|
||||
else
|
||||
commit_sha=$(echo "$pr_status" | jq -r .mergeCommit.oid)
|
||||
commit_shas+=("$commit_sha")
|
||||
merged_prs+=("$pr")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#unmerged_prs[@]} -gt 0 ]]; then
|
||||
echo "ALL_MERGED=false" >> "$GITHUB_OUTPUT"
|
||||
echo "UNMERGED_PRS=$(IFS=,; echo "${unmerged_prs[*]}")" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ALL_MERGED=true" >> "$GITHUB_OUTPUT"
|
||||
echo "COMMITS=$(IFS=,; echo "${commit_shas[*]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "PR_NUMBERS=$(IFS=,; echo "${merged_prs[*]}")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
gh pr view "${{ github.event.issue.number }}" --json mergeCommit,state > pr_status.json
|
||||
echo "MERGE_COMMIT_SHA=$(jq -r .mergeCommit.oid pr_status.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "STATE=$(jq -r .state pr_status.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Dispatch if Merged'
|
||||
if: "steps.get_commits.outputs.ALL_MERGED == 'true'"
|
||||
if: "steps.pr_status.outputs.STATE == 'MERGED'"
|
||||
id: 'dispatch_patch'
|
||||
uses: 'actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325'
|
||||
env:
|
||||
COMMENT_BODY: '${{ github.event.comment.body }}'
|
||||
COMMITS: '${{ steps.get_commits.outputs.COMMITS }}'
|
||||
PR_NUMBERS: '${{ steps.get_commits.outputs.PR_NUMBERS }}'
|
||||
with:
|
||||
github-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
@@ -97,10 +60,17 @@ jobs:
|
||||
// /patch preview
|
||||
if (commentBody.trim() === '/patch' || commentBody.trim() === '/patch both') {
|
||||
channels = ['stable', 'preview'];
|
||||
} else if (commentBody.includes('stable')) {
|
||||
} else if (commentBody.trim() === '/patch stable') {
|
||||
channels = ['stable'];
|
||||
} else if (commentBody.includes('preview')) {
|
||||
} else if (commentBody.trim() === '/patch preview') {
|
||||
channels = ['preview'];
|
||||
} else {
|
||||
// Fallback parsing for legacy formats
|
||||
if (commentBody.includes('channel=preview')) {
|
||||
channels = ['preview'];
|
||||
} else if (commentBody.includes('--channel preview')) {
|
||||
channels = ['preview'];
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Detected channels:', channels);
|
||||
@@ -117,9 +87,9 @@ jobs:
|
||||
workflow_id: 'release-patch-1-create-pr.yml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
commits: process.env.COMMITS,
|
||||
commit: '${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}',
|
||||
channel: channel,
|
||||
original_prs: process.env.PR_NUMBERS,
|
||||
original_pr: '${{ github.event.issue.number }}',
|
||||
environment: 'prod'
|
||||
}
|
||||
});
|
||||
@@ -153,13 +123,13 @@ jobs:
|
||||
}
|
||||
|
||||
- name: 'Comment on Failure'
|
||||
if: "startsWith(github.event.comment.body, '/patch') && steps.get_commits.outputs.ALL_MERGED == 'false'"
|
||||
if: "startsWith(github.event.comment.body, '/patch') && steps.pr_status.outputs.STATE != 'MERGED'"
|
||||
uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d'
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
issue-number: '${{ github.event.issue.number }}'
|
||||
body: |
|
||||
:x: The `/patch` command failed. The following pull request(s) must be merged before a patch can be created: #${{ steps.get_commits.outputs.UNMERGED_PRS }}
|
||||
:x: The `/patch` command failed. This pull request must be merged before a patch can be created.
|
||||
|
||||
- name: 'Final Status Comment - Success'
|
||||
if: "always() && startsWith(github.event.comment.body, '/patch') && steps.dispatch_patch.outcome == 'success' && steps.dispatch_patch.outputs.dispatched_run_urls"
|
||||
@@ -172,8 +142,7 @@ jobs:
|
||||
|
||||
**📋 Details:**
|
||||
- **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}`
|
||||
- **PRs**: `${{ steps.get_commits.outputs.PR_NUMBERS }}`
|
||||
- **Commits**: `${{ steps.get_commits.outputs.COMMITS }}`
|
||||
- **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}`
|
||||
- **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }}
|
||||
|
||||
**🔗 Track Progress:**
|
||||
@@ -191,8 +160,7 @@ jobs:
|
||||
|
||||
**📋 Details:**
|
||||
- **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}`
|
||||
- **PRs**: `${{ steps.get_commits.outputs.PR_NUMBERS }}`
|
||||
- **Commits**: `${{ steps.get_commits.outputs.COMMITS }}`
|
||||
- **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}`
|
||||
- **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }}
|
||||
|
||||
**🔗 Track Progress:**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
name: 'Release: Patch (1) Create PR'
|
||||
|
||||
run-name: >-
|
||||
Release Patch (1) Create PR | S:${{ inputs.channel }} | C:${{ inputs.commits }} ${{ inputs.original_prs && format('| PRs:#{0}', inputs.original_prs) || '' }}
|
||||
Release Patch (1) Create PR | S:${{ inputs.channel }} | C:${{ inputs.commit }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
commits:
|
||||
description: 'The commit SHAs to cherry-pick for the patch (comma-separated).'
|
||||
commit:
|
||||
description: 'The commit SHA to cherry-pick for the patch.'
|
||||
required: true
|
||||
type: 'string'
|
||||
channel:
|
||||
@@ -27,8 +27,8 @@ on:
|
||||
required: false
|
||||
type: 'string'
|
||||
default: 'main'
|
||||
original_prs:
|
||||
description: 'The original PR numbers to comment back on (comma-separated).'
|
||||
original_pr:
|
||||
description: 'The original PR number to comment back on.'
|
||||
required: false
|
||||
type: 'string'
|
||||
environment:
|
||||
@@ -85,9 +85,9 @@ jobs:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}'
|
||||
PATCH_COMMITS: '${{ github.event.inputs.commits }}'
|
||||
PATCH_COMMIT: '${{ github.event.inputs.commit }}'
|
||||
PATCH_CHANNEL: '${{ github.event.inputs.channel }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
DRY_RUN: '${{ github.event.inputs.dry_run }}'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
@@ -95,9 +95,9 @@ jobs:
|
||||
{
|
||||
node scripts/releasing/create-patch-pr.js \
|
||||
--cli-package-name="${CLI_PACKAGE_NAME}" \
|
||||
--commits="${PATCH_COMMITS}" \
|
||||
--commit="${PATCH_COMMIT}" \
|
||||
--channel="${PATCH_CHANNEL}" \
|
||||
--pullRequestNumbers="${ORIGINAL_PRS}" \
|
||||
--pullRequestNumber="${ORIGINAL_PR}" \
|
||||
--dry-run="${DRY_RUN}"
|
||||
} 2>&1 | tee >(
|
||||
echo "LOG_CONTENT<<EOF" >> "$GITHUB_ENV"
|
||||
@@ -107,12 +107,12 @@ jobs:
|
||||
echo "EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Comment on Original PR'
|
||||
if: 'always() && inputs.original_prs'
|
||||
if: 'always() && inputs.original_pr'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
EXIT_CODE: '${{ steps.create_patch.outputs.EXIT_CODE }}'
|
||||
COMMITS: '${{ github.event.inputs.commits }}'
|
||||
COMMIT: '${{ github.event.inputs.commit }}'
|
||||
CHANNEL: '${{ github.event.inputs.channel }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
GITHUB_RUN_ID: '${{ github.run_id }}'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 'Release: Patch (3) Release'
|
||||
|
||||
run-name: >-
|
||||
Release Patch (3) Release | T:${{ inputs.type }} | R:${{ inputs.release_ref }} ${{ inputs.original_prs && format('| PRs:#{0}', inputs.original_prs) || '' }}
|
||||
Release Patch (3) Release | T:${{ inputs.type }} | R:${{ inputs.release_ref }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -27,8 +27,8 @@ on:
|
||||
description: 'The branch, tag, or SHA to release from.'
|
||||
required: true
|
||||
type: 'string'
|
||||
original_prs:
|
||||
description: 'The original PR numbers to comment back on.'
|
||||
original_pr:
|
||||
description: 'The original PR number to comment back on.'
|
||||
required: false
|
||||
type: 'string'
|
||||
environment:
|
||||
@@ -208,10 +208,10 @@ jobs:
|
||||
--label 'release-failure,priority/p0'
|
||||
|
||||
- name: 'Comment Success on Original PR'
|
||||
if: '${{ success() && github.event.inputs.original_prs }}'
|
||||
if: '${{ success() && github.event.inputs.original_pr }}'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
SUCCESS: 'true'
|
||||
RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}'
|
||||
RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}'
|
||||
@@ -225,10 +225,10 @@ jobs:
|
||||
node scripts/releasing/patch-comment.js
|
||||
|
||||
- name: 'Comment Failure on Original PR'
|
||||
if: '${{ failure() && github.event.inputs.original_prs }}'
|
||||
if: '${{ failure() && github.event.inputs.original_pr }}'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
SUCCESS: 'false'
|
||||
RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}'
|
||||
RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}'
|
||||
|
||||
@@ -29,7 +29,11 @@ on:
|
||||
jobs:
|
||||
verify-release:
|
||||
environment: "${{ github.event.inputs.environment || 'prod' }}"
|
||||
runs-on: 'ubuntu-latest'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||
runs-on: '${{ matrix.os }}'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
packages: 'write'
|
||||
|
||||
@@ -55,6 +55,8 @@ powerful tool for developers.
|
||||
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
|
||||
signing the Google CLA.
|
||||
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
|
||||
Always activate the `pr-creator` skill for PR generation, even when using the
|
||||
`gh` CLI.
|
||||
- **Commit Messages:** Follow the
|
||||
[Conventional Commits](https://www.conventionalcommits.org/) standard.
|
||||
- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink)
|
||||
|
||||
@@ -18,6 +18,22 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.27.0 - 2026-02-03
|
||||
|
||||
- **Event-Driven Architecture:** The CLI now uses a new event-driven scheduler
|
||||
for tool execution, resulting in a more responsive and performant experience
|
||||
([#17078](https://github.com/google-gemini/gemini-cli/pull/17078) by
|
||||
@abhipatel12).
|
||||
- **Enhanced User Experience:** This release includes queued tool confirmations,
|
||||
and expandable large text pastes for a smoother workflow.
|
||||
- **New `/rewind` Command:** Easily navigate your session history with the new
|
||||
`/rewind` command
|
||||
([#15720](https://github.com/google-gemini/gemini-cli/pull/15720) by
|
||||
@Adib234).
|
||||
- **Linux Clipboard Support:** You can now paste images on Linux with Wayland
|
||||
and X11 ([#17144](https://github.com/google-gemini/gemini-cli/pull/17144) by
|
||||
@devr0306).
|
||||
|
||||
## Announcements: v0.26.0 - 2026-01-27
|
||||
|
||||
- **Agents and Skills:** We've introduced a new `skill-creator` skill
|
||||
|
||||
+427
-318
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.26.0
|
||||
# Latest stable release: v0.27.0
|
||||
|
||||
Released: January 27, 2026
|
||||
Released: February 3, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,328 +11,437 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Enhanced Agent and Skill Capabilities:** This release introduces the new
|
||||
`skill-creator` built-in skill, enables Agent Skills by default, and adds a
|
||||
generalist agent to improve task routing. Security for skill installation has
|
||||
also been enhanced with new consent prompts.
|
||||
- **Improved UI and UX:** A new "Rewind" feature lets you walk back through
|
||||
conversation history. We've also added an `/introspect` command for debugging
|
||||
and unified various shell confirmation dialogs for a more consistent user
|
||||
experience.
|
||||
- **Core Stability and Performance:** This release includes significant
|
||||
performance improvements, including a fix for PDF token estimation,
|
||||
optimizations for large inputs, and prevention of OOM crashes. Key memory
|
||||
management components like `LRUCache` have also been updated.
|
||||
- **Scheduler and Policy Refactoring:** The core tool scheduler has been
|
||||
decoupled into distinct orchestration, policy, and confirmation components,
|
||||
and we've added an experimental event-driven scheduler to improve performance
|
||||
and reliability.
|
||||
- **Event-Driven Architecture:** The CLI now uses an event-driven scheduler for
|
||||
tool execution, improving performance and responsiveness. This includes
|
||||
migrating non-interactive flows and sub-agents to the new scheduler.
|
||||
- **Enhanced User Experience:** This release introduces several UI/UX
|
||||
improvements, including queued tool confirmations and the ability to expand
|
||||
and collapse large pasted text blocks. The `Settings` dialog has been improved
|
||||
to reduce jitter and preserve focus.
|
||||
- **Agent and Skill Improvements:** Agent Skills have been promoted to a stable
|
||||
feature. Sub-agents now use a JSON schema for input and are tracked by an
|
||||
`AgentRegistry`.
|
||||
- **New `/rewind` Command:** A new `/rewind` command has been implemented to
|
||||
allow users to go back in their session history.
|
||||
- **Improved Shell and File Handling:** The shell tool's output format has been
|
||||
optimized, and the CLI now gracefully handles disk-full errors during chat
|
||||
recording. A bug in detecting already added paths has been fixed.
|
||||
- **Linux Clipboard Support:** Image pasting capabilities for Wayland and X11 on
|
||||
Linux have been added.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix: PDF token estimation (#16494) by @korade-krushna in
|
||||
[#16527](https://github.com/google-gemini/gemini-cli/pull/16527)
|
||||
- chore(release): bump version to 0.26.0-nightly.20260114.bb6c57414 by
|
||||
- remove fireAgent and beforeAgent hook by @ishaanxgupta in
|
||||
[#16919](https://github.com/google-gemini/gemini-cli/pull/16919)
|
||||
- Remove unused modelHooks and toolHooks by @ved015 in
|
||||
[#17115](https://github.com/google-gemini/gemini-cli/pull/17115)
|
||||
- feat(cli): sanitize ANSI escape sequences in non-interactive output by
|
||||
@sehoon38 in [#17172](https://github.com/google-gemini/gemini-cli/pull/17172)
|
||||
- Update Attempt text to Retry when showing the retry happening to the … by
|
||||
@sehoon38 in [#17178](https://github.com/google-gemini/gemini-cli/pull/17178)
|
||||
- chore(skills): update pr-creator skill workflow by @sehoon38 in
|
||||
[#17180](https://github.com/google-gemini/gemini-cli/pull/17180)
|
||||
- feat(cli): implement event-driven tool execution scheduler by @abhipatel12 in
|
||||
[#17078](https://github.com/google-gemini/gemini-cli/pull/17078)
|
||||
- chore(release): bump version to 0.27.0-nightly.20260121.97aac696f by
|
||||
@gemini-cli-robot in
|
||||
[#16604](https://github.com/google-gemini/gemini-cli/pull/16604)
|
||||
- docs: clarify F12 to open debug console by @jackwotherspoon in
|
||||
[#16570](https://github.com/google-gemini/gemini-cli/pull/16570)
|
||||
- docs: Remove .md extension from internal links in architecture.md by
|
||||
@medic-code in
|
||||
[#12899](https://github.com/google-gemini/gemini-cli/pull/12899)
|
||||
- Add an experimental setting for extension config by @chrstnb in
|
||||
[#16506](https://github.com/google-gemini/gemini-cli/pull/16506)
|
||||
- feat: add Rewind Confirmation dialog and Rewind Viewer component by @Adib234
|
||||
in [#15717](https://github.com/google-gemini/gemini-cli/pull/15717)
|
||||
- fix(a2a): Don't throw errors for GeminiEventType Retry and InvalidStream. by
|
||||
@ehedlund in [#16541](https://github.com/google-gemini/gemini-cli/pull/16541)
|
||||
- prefactor: add rootCommands as array so it can be used for policy parsing by
|
||||
[#17181](https://github.com/google-gemini/gemini-cli/pull/17181)
|
||||
- Remove other rewind reference in docs by @chrstnb in
|
||||
[#17149](https://github.com/google-gemini/gemini-cli/pull/17149)
|
||||
- feat(skills): add code-reviewer skill by @sehoon38 in
|
||||
[#17187](https://github.com/google-gemini/gemini-cli/pull/17187)
|
||||
- feat(plan): Extend Shift+Tab Mode Cycling to include Plan Mode by @Adib234 in
|
||||
[#17177](https://github.com/google-gemini/gemini-cli/pull/17177)
|
||||
- feat(plan): refactor TestRig and eval helper to support configurable approval
|
||||
modes by @jerop in
|
||||
[#17171](https://github.com/google-gemini/gemini-cli/pull/17171)
|
||||
- feat(workflows): support recursive workstream labeling and new IDs by
|
||||
@bdmorgan in [#17207](https://github.com/google-gemini/gemini-cli/pull/17207)
|
||||
- Run evals for all models. by @gundermanc in
|
||||
[#17123](https://github.com/google-gemini/gemini-cli/pull/17123)
|
||||
- fix(github): improve label-workstream-rollup efficiency with GraphQL by
|
||||
@bdmorgan in [#17217](https://github.com/google-gemini/gemini-cli/pull/17217)
|
||||
- Docs: Update changelogs for v.0.25.0 and v0.26.0-preview.0 releases. by
|
||||
@g-samroberts in
|
||||
[#17215](https://github.com/google-gemini/gemini-cli/pull/17215)
|
||||
- Migrate beforeTool and afterTool hooks to hookSystem by @ved015 in
|
||||
[#17204](https://github.com/google-gemini/gemini-cli/pull/17204)
|
||||
- fix(github): improve label-workstream-rollup efficiency and fix bugs by
|
||||
@bdmorgan in [#17219](https://github.com/google-gemini/gemini-cli/pull/17219)
|
||||
- feat(cli): improve skill enablement/disablement verbiage by @NTaylorMullen in
|
||||
[#17192](https://github.com/google-gemini/gemini-cli/pull/17192)
|
||||
- fix(admin): Ensure CLI commands run in non-interactive mode by @skeshive in
|
||||
[#17218](https://github.com/google-gemini/gemini-cli/pull/17218)
|
||||
- feat(core): support dynamic variable substitution in system prompt override by
|
||||
@NTaylorMullen in
|
||||
[#17042](https://github.com/google-gemini/gemini-cli/pull/17042)
|
||||
- fix(core,cli): enable recursive directory access for by @galz10 in
|
||||
[#17094](https://github.com/google-gemini/gemini-cli/pull/17094)
|
||||
- Docs: Marking for experimental features by @jkcinouye in
|
||||
[#16760](https://github.com/google-gemini/gemini-cli/pull/16760)
|
||||
- Support command/ctrl/alt backspace correctly by @scidomino in
|
||||
[#17175](https://github.com/google-gemini/gemini-cli/pull/17175)
|
||||
- feat(plan): add approval mode instructions to system prompt by @jerop in
|
||||
[#17151](https://github.com/google-gemini/gemini-cli/pull/17151)
|
||||
- feat(core): enable disableLLMCorrection by default by @SandyTao520 in
|
||||
[#17223](https://github.com/google-gemini/gemini-cli/pull/17223)
|
||||
- Remove unused slug from sidebar by @chrstnb in
|
||||
[#17229](https://github.com/google-gemini/gemini-cli/pull/17229)
|
||||
- drain stdin on exit by @scidomino in
|
||||
[#17241](https://github.com/google-gemini/gemini-cli/pull/17241)
|
||||
- refactor(cli): decouple UI from live tool execution via ToolActionsContext by
|
||||
@abhipatel12 in
|
||||
[#16640](https://github.com/google-gemini/gemini-cli/pull/16640)
|
||||
- remove unnecessary \x7f key bindings by @scidomino in
|
||||
[#16646](https://github.com/google-gemini/gemini-cli/pull/16646)
|
||||
- docs(skills): use body-file in pr-creator skill for better reliability by
|
||||
[#17183](https://github.com/google-gemini/gemini-cli/pull/17183)
|
||||
- fix(core): update token count and telemetry on /chat resume history load by
|
||||
@psinha40898 in
|
||||
[#16279](https://github.com/google-gemini/gemini-cli/pull/16279)
|
||||
- fix: /policy to display policies according to mode by @ishaanxgupta in
|
||||
[#16772](https://github.com/google-gemini/gemini-cli/pull/16772)
|
||||
- fix(core): simplify replace tool error message by @SandyTao520 in
|
||||
[#17246](https://github.com/google-gemini/gemini-cli/pull/17246)
|
||||
- feat(cli): consolidate shell inactivity and redirection monitoring by
|
||||
@NTaylorMullen in
|
||||
[#17086](https://github.com/google-gemini/gemini-cli/pull/17086)
|
||||
- fix(scheduler): prevent stale tool re-publication and fix stuck UI state by
|
||||
@abhipatel12 in
|
||||
[#16642](https://github.com/google-gemini/gemini-cli/pull/16642)
|
||||
- chore(automation): recursive labeling for workstream descendants by @bdmorgan
|
||||
in [#16609](https://github.com/google-gemini/gemini-cli/pull/16609)
|
||||
- feat: introduce 'skill-creator' built-in skill and CJS management tools by
|
||||
@NTaylorMullen in
|
||||
[#16394](https://github.com/google-gemini/gemini-cli/pull/16394)
|
||||
- chore(automation): remove automated PR size and complexity labeler by
|
||||
@bdmorgan in [#16648](https://github.com/google-gemini/gemini-cli/pull/16648)
|
||||
- refactor(skills): replace 'project' with 'workspace' scope by @NTaylorMullen
|
||||
in [#16380](https://github.com/google-gemini/gemini-cli/pull/16380)
|
||||
- Docs: Update release notes for 1/13/2026 by @jkcinouye in
|
||||
[#16583](https://github.com/google-gemini/gemini-cli/pull/16583)
|
||||
- Simplify paste handling by @scidomino in
|
||||
[#16654](https://github.com/google-gemini/gemini-cli/pull/16654)
|
||||
- chore(automation): improve scheduled issue triage discovery and throughput by
|
||||
@bdmorgan in [#16652](https://github.com/google-gemini/gemini-cli/pull/16652)
|
||||
- fix(acp): run exit cleanup when stdin closes by @codefromthecrypt in
|
||||
[#14953](https://github.com/google-gemini/gemini-cli/pull/14953)
|
||||
- feat(scheduler): add types needed for event driven scheduler by @abhipatel12
|
||||
in [#16641](https://github.com/google-gemini/gemini-cli/pull/16641)
|
||||
- Remove unused rewind key binding by @scidomino in
|
||||
[#16659](https://github.com/google-gemini/gemini-cli/pull/16659)
|
||||
- Remove sequence binding by @scidomino in
|
||||
[#16664](https://github.com/google-gemini/gemini-cli/pull/16664)
|
||||
- feat(cli): undeprecate the --prompt flag by @alexaustin007 in
|
||||
[#13981](https://github.com/google-gemini/gemini-cli/pull/13981)
|
||||
- chore: update dependabot configuration by @cosmopax in
|
||||
[#13507](https://github.com/google-gemini/gemini-cli/pull/13507)
|
||||
- feat(config): add 'auto' alias for default model selection by @sehoon38 in
|
||||
[#16661](https://github.com/google-gemini/gemini-cli/pull/16661)
|
||||
- Enable & disable agents by @sehoon38 in
|
||||
[#16225](https://github.com/google-gemini/gemini-cli/pull/16225)
|
||||
- cleanup: Improve keybindings by @scidomino in
|
||||
[#16672](https://github.com/google-gemini/gemini-cli/pull/16672)
|
||||
- Add timeout for shell-utils to prevent hangs. by @jacob314 in
|
||||
[#16667](https://github.com/google-gemini/gemini-cli/pull/16667)
|
||||
- feat(plan): add experimental plan flag by @jerop in
|
||||
[#16650](https://github.com/google-gemini/gemini-cli/pull/16650)
|
||||
- feat(cli): add security consent prompts for skill installation by
|
||||
@NTaylorMullen in
|
||||
[#16549](https://github.com/google-gemini/gemini-cli/pull/16549)
|
||||
- fix: replace 3 consecutive periods with ellipsis character by @Vist233 in
|
||||
[#16587](https://github.com/google-gemini/gemini-cli/pull/16587)
|
||||
- chore(automation): ensure status/need-triage is applied and never cleared
|
||||
automatically by @bdmorgan in
|
||||
[#16657](https://github.com/google-gemini/gemini-cli/pull/16657)
|
||||
- fix: Handle colons in skill description frontmatter by @maru0804 in
|
||||
[#16345](https://github.com/google-gemini/gemini-cli/pull/16345)
|
||||
- refactor(core): harden skill frontmatter parsing by @NTaylorMullen in
|
||||
[#16705](https://github.com/google-gemini/gemini-cli/pull/16705)
|
||||
- feat(skills): add conflict detection and warnings for skill overrides by
|
||||
@NTaylorMullen in
|
||||
[#16709](https://github.com/google-gemini/gemini-cli/pull/16709)
|
||||
- feat(scheduler): add SchedulerStateManager for reactive tool state by
|
||||
@abhipatel12 in
|
||||
[#16651](https://github.com/google-gemini/gemini-cli/pull/16651)
|
||||
- chore(automation): enforce 'help wanted' label permissions and update
|
||||
guidelines by @bdmorgan in
|
||||
[#16707](https://github.com/google-gemini/gemini-cli/pull/16707)
|
||||
- fix(core): resolve circular dependency via tsconfig paths by @sehoon38 in
|
||||
[#16730](https://github.com/google-gemini/gemini-cli/pull/16730)
|
||||
- chore/release: bump version to 0.26.0-nightly.20260115.6cb3ae4e0 by
|
||||
@gemini-cli-robot in
|
||||
[#16738](https://github.com/google-gemini/gemini-cli/pull/16738)
|
||||
- fix(automation): correct status/need-issue label matching wildcard by
|
||||
@bdmorgan in [#16727](https://github.com/google-gemini/gemini-cli/pull/16727)
|
||||
- fix(automation): prevent label-enforcer loop by ignoring all bots by @bdmorgan
|
||||
in [#16746](https://github.com/google-gemini/gemini-cli/pull/16746)
|
||||
- Add links to supported locations and minor fixes by @g-samroberts in
|
||||
[#16476](https://github.com/google-gemini/gemini-cli/pull/16476)
|
||||
- feat(policy): add source tracking to policy rules by @allenhutchison in
|
||||
[#16670](https://github.com/google-gemini/gemini-cli/pull/16670)
|
||||
- feat(automation): enforce '🔒 maintainer only' and fix bot loop by @bdmorgan
|
||||
in [#16751](https://github.com/google-gemini/gemini-cli/pull/16751)
|
||||
- Make merged settings non-nullable and fix all lints related to that. by
|
||||
@jacob314 in [#16647](https://github.com/google-gemini/gemini-cli/pull/16647)
|
||||
- fix(core): prevent ModelInfo event emission on aborted signal by @sehoon38 in
|
||||
[#16752](https://github.com/google-gemini/gemini-cli/pull/16752)
|
||||
- Replace relative paths to fix website build by @chrstnb in
|
||||
[#16755](https://github.com/google-gemini/gemini-cli/pull/16755)
|
||||
- Restricting to localhost by @cocosheng-g in
|
||||
[#16548](https://github.com/google-gemini/gemini-cli/pull/16548)
|
||||
- fix(cli): add explicit dependency on color-convert by @sehoon38 in
|
||||
[#16757](https://github.com/google-gemini/gemini-cli/pull/16757)
|
||||
- fix(automation): robust label enforcement with permission checks by @bdmorgan
|
||||
in [#16762](https://github.com/google-gemini/gemini-cli/pull/16762)
|
||||
- fix(cli): prevent OOM crash by limiting file search traversal and adding
|
||||
timeout by @galz10 in
|
||||
[#16696](https://github.com/google-gemini/gemini-cli/pull/16696)
|
||||
- fix(cli): safely handle /dev/tty access on macOS by @korade-krushna in
|
||||
[#16531](https://github.com/google-gemini/gemini-cli/pull/16531)
|
||||
- docs: clarify workspace test execution in GEMINI.md by @mattKorwel in
|
||||
[#16764](https://github.com/google-gemini/gemini-cli/pull/16764)
|
||||
- Add support for running available commands prior to MCP servers loading by
|
||||
@Adib234 in [#15596](https://github.com/google-gemini/gemini-cli/pull/15596)
|
||||
- feat(plan): add experimental 'plan' approval mode by @jerop in
|
||||
[#16753](https://github.com/google-gemini/gemini-cli/pull/16753)
|
||||
- feat(scheduler): add functional awaitConfirmation utility by @abhipatel12 in
|
||||
[#16721](https://github.com/google-gemini/gemini-cli/pull/16721)
|
||||
- fix(infra): update maintainer rollup label to 'workstream-rollup' by @bdmorgan
|
||||
in [#16809](https://github.com/google-gemini/gemini-cli/pull/16809)
|
||||
- fix(infra): use GraphQL to detect direct parents in rollup workflow by
|
||||
@bdmorgan in [#16811](https://github.com/google-gemini/gemini-cli/pull/16811)
|
||||
- chore(workflows): rename label-workstream-rollup workflow by @bdmorgan in
|
||||
[#16818](https://github.com/google-gemini/gemini-cli/pull/16818)
|
||||
- skip simple-mcp-server.test.ts by @scidomino in
|
||||
[#16842](https://github.com/google-gemini/gemini-cli/pull/16842)
|
||||
- Steer outer agent to use expert subagents when present by @gundermanc in
|
||||
[#16763](https://github.com/google-gemini/gemini-cli/pull/16763)
|
||||
- Fix race condition by awaiting scheduleToolCalls by @chrstnb in
|
||||
[#16759](https://github.com/google-gemini/gemini-cli/pull/16759)
|
||||
- cleanup: Organize key bindings by @scidomino in
|
||||
[#16798](https://github.com/google-gemini/gemini-cli/pull/16798)
|
||||
- feat(core): Add generalist agent. by @joshualitt in
|
||||
[#16638](https://github.com/google-gemini/gemini-cli/pull/16638)
|
||||
- perf(ui): optimize text buffer and highlighting for large inputs by
|
||||
@NTaylorMullen in
|
||||
[#16782](https://github.com/google-gemini/gemini-cli/pull/16782)
|
||||
- fix(core): fix PTY descriptor shell leak by @galz10 in
|
||||
[#16773](https://github.com/google-gemini/gemini-cli/pull/16773)
|
||||
- feat(plan): enforce strict read-only policy and halt execution on violation by
|
||||
@jerop in [#16849](https://github.com/google-gemini/gemini-cli/pull/16849)
|
||||
- remove need-triage label from bug_report template by @sehoon38 in
|
||||
[#16864](https://github.com/google-gemini/gemini-cli/pull/16864)
|
||||
- fix(core): truncate large telemetry log entries by @sehoon38 in
|
||||
[#16769](https://github.com/google-gemini/gemini-cli/pull/16769)
|
||||
- docs(extensions): add Agent Skills support and mark feature as experimental by
|
||||
@NTaylorMullen in
|
||||
[#16859](https://github.com/google-gemini/gemini-cli/pull/16859)
|
||||
- fix(core): surface warnings for invalid hook event names in configuration
|
||||
(#16788) by @sehoon38 in
|
||||
[#16873](https://github.com/google-gemini/gemini-cli/pull/16873)
|
||||
- feat(plan): remove read_many_files from approval mode policies by @jerop in
|
||||
[#16876](https://github.com/google-gemini/gemini-cli/pull/16876)
|
||||
- feat(admin): implement admin controls polling and restart prompt by @skeshive
|
||||
in [#16627](https://github.com/google-gemini/gemini-cli/pull/16627)
|
||||
- Remove LRUCache class migrating to mnemoist by @jacob314 in
|
||||
[#16872](https://github.com/google-gemini/gemini-cli/pull/16872)
|
||||
- feat(settings): rename negative settings to positive naming (disable* ->
|
||||
enable*) by @afarber in
|
||||
[#14142](https://github.com/google-gemini/gemini-cli/pull/14142)
|
||||
- refactor(cli): unify shell confirmation dialogs by @NTaylorMullen in
|
||||
[#16828](https://github.com/google-gemini/gemini-cli/pull/16828)
|
||||
- feat(agent): enable agent skills by default by @NTaylorMullen in
|
||||
[#16736](https://github.com/google-gemini/gemini-cli/pull/16736)
|
||||
- refactor(core): foundational truncation refactoring and token estimation
|
||||
optimization by @NTaylorMullen in
|
||||
[#16824](https://github.com/google-gemini/gemini-cli/pull/16824)
|
||||
- fix(hooks): enable /hooks disable to reliably stop single hooks by
|
||||
@abhipatel12 in
|
||||
[#16804](https://github.com/google-gemini/gemini-cli/pull/16804)
|
||||
- Don't commit unless user asks us to. by @gundermanc in
|
||||
[#16902](https://github.com/google-gemini/gemini-cli/pull/16902)
|
||||
- chore: remove a2a-adapter and bump @a2a-js/sdk to 0.3.8 by @adamfweidman in
|
||||
[#16800](https://github.com/google-gemini/gemini-cli/pull/16800)
|
||||
- fix: Show experiment values in settings UI for compressionThreshold by
|
||||
@ishaanxgupta in
|
||||
[#16267](https://github.com/google-gemini/gemini-cli/pull/16267)
|
||||
- feat(cli): replace relative keyboard shortcuts link with web URL by
|
||||
@imaliabbas in
|
||||
[#16479](https://github.com/google-gemini/gemini-cli/pull/16479)
|
||||
- fix(core): resolve PKCE length issue and stabilize OAuth redirect port by
|
||||
@sehoon38 in [#16815](https://github.com/google-gemini/gemini-cli/pull/16815)
|
||||
- Delete rewind documentation for now by @Adib234 in
|
||||
[#16932](https://github.com/google-gemini/gemini-cli/pull/16932)
|
||||
- Stabilize skill-creator CI and package format by @NTaylorMullen in
|
||||
[#17001](https://github.com/google-gemini/gemini-cli/pull/17001)
|
||||
- Stabilize the git evals by @gundermanc in
|
||||
[#16989](https://github.com/google-gemini/gemini-cli/pull/16989)
|
||||
- fix(core): attempt compression before context overflow check by @NTaylorMullen
|
||||
in [#16914](https://github.com/google-gemini/gemini-cli/pull/16914)
|
||||
- Fix inverted logic. by @gundermanc in
|
||||
[#17007](https://github.com/google-gemini/gemini-cli/pull/17007)
|
||||
- chore(scripts): add duplicate issue closer script and fix lint errors by
|
||||
@bdmorgan in [#16997](https://github.com/google-gemini/gemini-cli/pull/16997)
|
||||
- docs: update README and config guide to reference Gemini 3 by @JayadityaGit in
|
||||
[#15806](https://github.com/google-gemini/gemini-cli/pull/15806)
|
||||
- fix(cli): correct Homebrew installation detection by @kij in
|
||||
[#14727](https://github.com/google-gemini/gemini-cli/pull/14727)
|
||||
- Demote git evals to nightly run. by @gundermanc in
|
||||
[#17030](https://github.com/google-gemini/gemini-cli/pull/17030)
|
||||
- fix(cli): use OSC-52 clipboard copy in Windows Terminal by @Thomas-Shephard in
|
||||
[#16920](https://github.com/google-gemini/gemini-cli/pull/16920)
|
||||
- Fix: Process all parts in response chunks when thought is first by @pyrytakala
|
||||
in [#13539](https://github.com/google-gemini/gemini-cli/pull/13539)
|
||||
- fix(automation): fix jq quoting error in pr-triage.sh by @Kimsoo0119 in
|
||||
[#16958](https://github.com/google-gemini/gemini-cli/pull/16958)
|
||||
- refactor(core): decouple scheduler into orchestration, policy, and
|
||||
confirmation by @abhipatel12 in
|
||||
[#16895](https://github.com/google-gemini/gemini-cli/pull/16895)
|
||||
- feat: add /introspect slash command by @NTaylorMullen in
|
||||
[#17048](https://github.com/google-gemini/gemini-cli/pull/17048)
|
||||
- refactor(cli): centralize tool mapping and decouple legacy scheduler by
|
||||
@abhipatel12 in
|
||||
[#17044](https://github.com/google-gemini/gemini-cli/pull/17044)
|
||||
- fix(ui): ensure rationale renders before tool calls by @NTaylorMullen in
|
||||
[#17043](https://github.com/google-gemini/gemini-cli/pull/17043)
|
||||
- fix(workflows): use author_association for maintainer check by @bdmorgan in
|
||||
[#17060](https://github.com/google-gemini/gemini-cli/pull/17060)
|
||||
- fix return type of fireSessionStartEvent to defaultHookOutput by @ved015 in
|
||||
[#16833](https://github.com/google-gemini/gemini-cli/pull/16833)
|
||||
- feat(cli): add experiment gate for event-driven scheduler by @abhipatel12 in
|
||||
[#17055](https://github.com/google-gemini/gemini-cli/pull/17055)
|
||||
- feat(core): improve shell redirection transparency and security by
|
||||
@NTaylorMullen in
|
||||
[#16486](https://github.com/google-gemini/gemini-cli/pull/16486)
|
||||
- fix(core): deduplicate ModelInfo emission in GeminiClient by @NTaylorMullen in
|
||||
[#17075](https://github.com/google-gemini/gemini-cli/pull/17075)
|
||||
- docs(themes): remove unsupported DiffModified color key by @jw409 in
|
||||
[#17073](https://github.com/google-gemini/gemini-cli/pull/17073)
|
||||
- fix: update currentSequenceModel when modelChanged by @adamfweidman in
|
||||
[#17051](https://github.com/google-gemini/gemini-cli/pull/17051)
|
||||
- feat(core): enhanced anchored iterative context compression with
|
||||
self-verification by @rmedranollamas in
|
||||
[#15710](https://github.com/google-gemini/gemini-cli/pull/15710)
|
||||
- Fix mcp instructions by @chrstnb in
|
||||
[#16439](https://github.com/google-gemini/gemini-cli/pull/16439)
|
||||
- [A2A] Disable checkpointing if git is not installed by @cocosheng-g in
|
||||
[#16896](https://github.com/google-gemini/gemini-cli/pull/16896)
|
||||
- feat(admin): set admin.skills.enabled based on advancedFeaturesEnabled setting
|
||||
by @skeshive in
|
||||
[#17095](https://github.com/google-gemini/gemini-cli/pull/17095)
|
||||
- Test coverage for hook exit code cases by @gundermanc in
|
||||
[#17041](https://github.com/google-gemini/gemini-cli/pull/17041)
|
||||
- Revert "Revert "Update extension examples"" by @chrstnb in
|
||||
[#16445](https://github.com/google-gemini/gemini-cli/pull/16445)
|
||||
- fix(core): Provide compact, actionable errors for agent delegation failures by
|
||||
[#17227](https://github.com/google-gemini/gemini-cli/pull/17227)
|
||||
- feat(config): default enableEventDrivenScheduler to true by @abhipatel12 in
|
||||
[#17211](https://github.com/google-gemini/gemini-cli/pull/17211)
|
||||
- feat(hooks): enable hooks system by default by @abhipatel12 in
|
||||
[#17247](https://github.com/google-gemini/gemini-cli/pull/17247)
|
||||
- feat(core): Enable AgentRegistry to track all discovered subagents by
|
||||
@SandyTao520 in
|
||||
[#16493](https://github.com/google-gemini/gemini-cli/pull/16493)
|
||||
- fix: migrate BeforeModel and AfterModel hooks to HookSystem by @ved015 in
|
||||
[#16599](https://github.com/google-gemini/gemini-cli/pull/16599)
|
||||
- feat(admin): apply admin settings to gemini skills/mcp/extensions commands by
|
||||
@skeshive in [#17102](https://github.com/google-gemini/gemini-cli/pull/17102)
|
||||
- fix(core): update telemetry token count after session resume by @psinha40898
|
||||
in [#15491](https://github.com/google-gemini/gemini-cli/pull/15491)
|
||||
- Demote the subagent test to nightly by @gundermanc in
|
||||
[#17105](https://github.com/google-gemini/gemini-cli/pull/17105)
|
||||
- feat(plan): telemetry to track adoption and usage of plan mode by @Adib234 in
|
||||
[#16863](https://github.com/google-gemini/gemini-cli/pull/16863)
|
||||
- feat: Add flash lite utility fallback chain by @adamfweidman in
|
||||
[#17056](https://github.com/google-gemini/gemini-cli/pull/17056)
|
||||
- Fixes Windows crash: "Cannot resize a pty that has already exited" by @dzammit
|
||||
in [#15757](https://github.com/google-gemini/gemini-cli/pull/15757)
|
||||
- feat(core): Add initial eval for generalist agent. by @joshualitt in
|
||||
[#16856](https://github.com/google-gemini/gemini-cli/pull/16856)
|
||||
- feat(core): unify agent enabled and disabled flags by @SandyTao520 in
|
||||
[#17127](https://github.com/google-gemini/gemini-cli/pull/17127)
|
||||
- fix(core): resolve auto model in default strategy by @sehoon38 in
|
||||
[#17116](https://github.com/google-gemini/gemini-cli/pull/17116)
|
||||
- docs: update project context and pr-creator workflow by @NTaylorMullen in
|
||||
[#17119](https://github.com/google-gemini/gemini-cli/pull/17119)
|
||||
- fix(cli): send gemini-cli version as mcp client version by @dsp in
|
||||
[#13407](https://github.com/google-gemini/gemini-cli/pull/13407)
|
||||
- fix(cli): resolve Ctrl+Enter and Ctrl+J newline issues by @imadraude in
|
||||
[#17021](https://github.com/google-gemini/gemini-cli/pull/17021)
|
||||
- Remove missing sidebar item by @chrstnb in
|
||||
[#17145](https://github.com/google-gemini/gemini-cli/pull/17145)
|
||||
- feat(core): Ensure all properties in hooks object are event names. by
|
||||
@joshualitt in
|
||||
[#16870](https://github.com/google-gemini/gemini-cli/pull/16870)
|
||||
- fix(cli): fix newline support broken in previous PR by @scidomino in
|
||||
[#17159](https://github.com/google-gemini/gemini-cli/pull/17159)
|
||||
- Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors.
|
||||
by @gsquared94 in
|
||||
[#16231](https://github.com/google-gemini/gemini-cli/pull/16231)
|
||||
- Add Esc-Esc to clear prompt when it's not empty by @Adib234 in
|
||||
[#17131](https://github.com/google-gemini/gemini-cli/pull/17131)
|
||||
- Avoid spurious warnings about unexpected renders triggered by appEvents and
|
||||
coreEvents. by @jacob314 in
|
||||
[#17160](https://github.com/google-gemini/gemini-cli/pull/17160)
|
||||
- fix(cli): resolve home/end keybinding conflict by @scidomino in
|
||||
[#17124](https://github.com/google-gemini/gemini-cli/pull/17124)
|
||||
- fix(cli): display 'http' type on mcp list by @pamanta in
|
||||
[#16915](https://github.com/google-gemini/gemini-cli/pull/16915)
|
||||
- fix bad fallback logic external editor logic by @scidomino in
|
||||
[#17166](https://github.com/google-gemini/gemini-cli/pull/17166)
|
||||
- Fix bug where System scopes weren't migrated. by @jacob314 in
|
||||
[#17174](https://github.com/google-gemini/gemini-cli/pull/17174)
|
||||
- Fix mcp tool lookup in tool registry by @werdnum in
|
||||
[#17054](https://github.com/google-gemini/gemini-cli/pull/17054)
|
||||
[#17253](https://github.com/google-gemini/gemini-cli/pull/17253)
|
||||
- feat(core): Have subagents use a JSON schema type for input. by @joshualitt in
|
||||
[#17152](https://github.com/google-gemini/gemini-cli/pull/17152)
|
||||
- feat: replace large text pastes with [Pasted Text: X lines] placeholder by
|
||||
@jackwotherspoon in
|
||||
[#16422](https://github.com/google-gemini/gemini-cli/pull/16422)
|
||||
- security(hooks): Wrap hook-injected context in distinct XML tags by @yunaseoul
|
||||
in [#17237](https://github.com/google-gemini/gemini-cli/pull/17237)
|
||||
- Enable the ability to queue specific nightly eval tests by @gundermanc in
|
||||
[#17262](https://github.com/google-gemini/gemini-cli/pull/17262)
|
||||
- docs(hooks): comprehensive update of hook documentation and specs by
|
||||
@abhipatel12 in
|
||||
[#16816](https://github.com/google-gemini/gemini-cli/pull/16816)
|
||||
- refactor: improve large text paste placeholder by @jacob314 in
|
||||
[#17269](https://github.com/google-gemini/gemini-cli/pull/17269)
|
||||
- feat: implement /rewind command by @Adib234 in
|
||||
[#15720](https://github.com/google-gemini/gemini-cli/pull/15720)
|
||||
- Feature/jetbrains ide detection by @SoLoHiC in
|
||||
[#16243](https://github.com/google-gemini/gemini-cli/pull/16243)
|
||||
- docs: update typo in mcp-server.md file by @schifferl in
|
||||
[#17099](https://github.com/google-gemini/gemini-cli/pull/17099)
|
||||
- Sanitize command names and descriptions by @ehedlund in
|
||||
[#17228](https://github.com/google-gemini/gemini-cli/pull/17228)
|
||||
- fix(auth): don't crash when initial auth fails by @skeshive in
|
||||
[#17308](https://github.com/google-gemini/gemini-cli/pull/17308)
|
||||
- Added image pasting capabilities for Wayland and X11 on Linux by @devr0306 in
|
||||
[#17144](https://github.com/google-gemini/gemini-cli/pull/17144)
|
||||
- feat: add AskUser tool schema by @jackwotherspoon in
|
||||
[#16988](https://github.com/google-gemini/gemini-cli/pull/16988)
|
||||
- fix cli settings: resolve layout jitter in settings bar by @Mag1ck in
|
||||
[#16256](https://github.com/google-gemini/gemini-cli/pull/16256)
|
||||
- fix: show whitespace changes in edit tool diffs by @Ujjiyara in
|
||||
[#17213](https://github.com/google-gemini/gemini-cli/pull/17213)
|
||||
- Remove redundant calls setting linuxClipboardTool. getUserLinuxClipboardTool()
|
||||
now handles the caching internally by @jacob314 in
|
||||
[#17320](https://github.com/google-gemini/gemini-cli/pull/17320)
|
||||
- ci: allow failure in evals-nightly run step by @gundermanc in
|
||||
[#17319](https://github.com/google-gemini/gemini-cli/pull/17319)
|
||||
- feat(cli): Add state management and plumbing for agent configuration dialog by
|
||||
@SandyTao520 in
|
||||
[#17259](https://github.com/google-gemini/gemini-cli/pull/17259)
|
||||
- bug: fix ide-client connection to ide-companion when inside docker via
|
||||
ssh/devcontainer by @kapsner in
|
||||
[#15049](https://github.com/google-gemini/gemini-cli/pull/15049)
|
||||
- Emit correct newline type return by @scidomino in
|
||||
[#17331](https://github.com/google-gemini/gemini-cli/pull/17331)
|
||||
- New skill: docs-writer by @g-samroberts in
|
||||
[#17268](https://github.com/google-gemini/gemini-cli/pull/17268)
|
||||
- fix(core): Resolve AbortSignal MaxListenersExceededWarning (#5950) by
|
||||
@spencer426 in
|
||||
[#16735](https://github.com/google-gemini/gemini-cli/pull/16735)
|
||||
- Disable tips after 10 runs by @Adib234 in
|
||||
[#17101](https://github.com/google-gemini/gemini-cli/pull/17101)
|
||||
- Fix so rewind starts at the bottom and loadHistory refreshes static content.
|
||||
by @jacob314 in
|
||||
[#17335](https://github.com/google-gemini/gemini-cli/pull/17335)
|
||||
- feat(core): Remove legacy settings. by @joshualitt in
|
||||
[#17244](https://github.com/google-gemini/gemini-cli/pull/17244)
|
||||
- feat(plan): add 'communicate' tool kind by @jerop in
|
||||
[#17341](https://github.com/google-gemini/gemini-cli/pull/17341)
|
||||
- feat(routing): A/B Test Numerical Complexity Scoring for Gemini 3 by
|
||||
@mattKorwel in
|
||||
[#16041](https://github.com/google-gemini/gemini-cli/pull/16041)
|
||||
- feat(plan): update UI Theme for Plan Mode by @Adib234 in
|
||||
[#17243](https://github.com/google-gemini/gemini-cli/pull/17243)
|
||||
- fix(ui): stabilize rendering during terminal resize in alternate buffer by
|
||||
@lkk214 in [#15783](https://github.com/google-gemini/gemini-cli/pull/15783)
|
||||
- feat(cli): add /agents config command and improve agent discovery by
|
||||
@SandyTao520 in
|
||||
[#17342](https://github.com/google-gemini/gemini-cli/pull/17342)
|
||||
- feat(mcp): add enable/disable commands for MCP servers (#11057) by @jasmeetsb
|
||||
in [#16299](https://github.com/google-gemini/gemini-cli/pull/16299)
|
||||
- fix(cli)!: Default to interactive mode for positional arguments by
|
||||
@ishaanxgupta in
|
||||
[#16329](https://github.com/google-gemini/gemini-cli/pull/16329)
|
||||
- Fix issue #17080 by @jacob314 in
|
||||
[#17100](https://github.com/google-gemini/gemini-cli/pull/17100)
|
||||
- feat(core): Refresh agents after loading an extension. by @joshualitt in
|
||||
[#17355](https://github.com/google-gemini/gemini-cli/pull/17355)
|
||||
- fix(cli): include source in policy rule display by @allenhutchison in
|
||||
[#17358](https://github.com/google-gemini/gemini-cli/pull/17358)
|
||||
- fix: remove obsolete CloudCode PerDay quota and 120s terminal threshold by
|
||||
@gsquared94 in
|
||||
[#17236](https://github.com/google-gemini/gemini-cli/pull/17236)
|
||||
- Refactor subagent delegation to be one tool per agent by @gundermanc in
|
||||
[#17346](https://github.com/google-gemini/gemini-cli/pull/17346)
|
||||
- fix(core): Include MCP server name in OAuth message by @jerop in
|
||||
[#17351](https://github.com/google-gemini/gemini-cli/pull/17351)
|
||||
- Fix pr-triage.sh script to update pull requests with tags "help wanted" and
|
||||
"maintainer only" by @jacob314 in
|
||||
[#17324](https://github.com/google-gemini/gemini-cli/pull/17324)
|
||||
- feat(plan): implement simple workflow for planning in main agent by @jerop in
|
||||
[#17326](https://github.com/google-gemini/gemini-cli/pull/17326)
|
||||
- fix: exit with non-zero code when esbuild is missing by @yuvrajangadsingh in
|
||||
[#16967](https://github.com/google-gemini/gemini-cli/pull/16967)
|
||||
- fix: ensure @docs/cli/custom-commands.md UI message ordering and test by
|
||||
@medic-code in
|
||||
[#12038](https://github.com/google-gemini/gemini-cli/pull/12038)
|
||||
- fix(core): add alternative command names for Antigravity editor detec… by
|
||||
@baeseokjae in
|
||||
[#16829](https://github.com/google-gemini/gemini-cli/pull/16829)
|
||||
- Refactor: Migrate CLI appEvents to Core coreEvents by @Adib234 in
|
||||
[#15737](https://github.com/google-gemini/gemini-cli/pull/15737)
|
||||
- fix(core): await MCP initialization in non-interactive mode by @Ratish1 in
|
||||
[#17390](https://github.com/google-gemini/gemini-cli/pull/17390)
|
||||
- Fix modifyOtherKeys enablement on unsupported terminals by @seekskyworld in
|
||||
[#16714](https://github.com/google-gemini/gemini-cli/pull/16714)
|
||||
- fix(core): gracefully handle disk full errors in chat recording by
|
||||
@godwiniheuwa in
|
||||
[#17305](https://github.com/google-gemini/gemini-cli/pull/17305)
|
||||
- fix(oauth): update oauth to use 127.0.0.1 instead of localhost by @skeshive in
|
||||
[#17388](https://github.com/google-gemini/gemini-cli/pull/17388)
|
||||
- fix(core): use RFC 9728 compliant path-based OAuth protected resource
|
||||
discovery by @vrv in
|
||||
[#15756](https://github.com/google-gemini/gemini-cli/pull/15756)
|
||||
- Update Code Wiki README badge by @PatoBeltran in
|
||||
[#15229](https://github.com/google-gemini/gemini-cli/pull/15229)
|
||||
- Add conda installation instructions for Gemini CLI by @ishaanxgupta in
|
||||
[#16921](https://github.com/google-gemini/gemini-cli/pull/16921)
|
||||
- chore(refactor): extract BaseSettingsDialog component by @SandyTao520 in
|
||||
[#17369](https://github.com/google-gemini/gemini-cli/pull/17369)
|
||||
- fix(cli): preserve input text when declining tool approval (#15624) by
|
||||
@ManojINaik in
|
||||
[#15659](https://github.com/google-gemini/gemini-cli/pull/15659)
|
||||
- chore: upgrade dep: diff 7.0.0-> 8.0.3 by @scidomino in
|
||||
[#17403](https://github.com/google-gemini/gemini-cli/pull/17403)
|
||||
- feat: add AskUserDialog for UI component of AskUser tool by @jackwotherspoon
|
||||
in [#17344](https://github.com/google-gemini/gemini-cli/pull/17344)
|
||||
- feat(ui): display user tier in about command by @sehoon38 in
|
||||
[#17400](https://github.com/google-gemini/gemini-cli/pull/17400)
|
||||
- feat: add clearContext to AfterAgent hooks by @jackwotherspoon in
|
||||
[#16574](https://github.com/google-gemini/gemini-cli/pull/16574)
|
||||
- fix(cli): change image paste location to global temp directory (#17396) by
|
||||
@devr0306 in [#17396](https://github.com/google-gemini/gemini-cli/pull/17396)
|
||||
- Fix line endings issue with Notice file by @scidomino in
|
||||
[#17417](https://github.com/google-gemini/gemini-cli/pull/17417)
|
||||
- feat(plan): implement persistent approvalMode setting by @Adib234 in
|
||||
[#17350](https://github.com/google-gemini/gemini-cli/pull/17350)
|
||||
- feat(ui): Move keyboard handling into BaseSettingsDialog by @SandyTao520 in
|
||||
[#17404](https://github.com/google-gemini/gemini-cli/pull/17404)
|
||||
- Allow prompt queueing during MCP initialization by @Adib234 in
|
||||
[#17395](https://github.com/google-gemini/gemini-cli/pull/17395)
|
||||
- feat: implement AgentConfigDialog for /agents config command by @SandyTao520
|
||||
in [#17370](https://github.com/google-gemini/gemini-cli/pull/17370)
|
||||
- fix(agents): default to all tools when tool list is omitted in subagents by
|
||||
@gundermanc in
|
||||
[#17422](https://github.com/google-gemini/gemini-cli/pull/17422)
|
||||
- feat(cli): Moves tool confirmations to a queue UX by @abhipatel12 in
|
||||
[#17276](https://github.com/google-gemini/gemini-cli/pull/17276)
|
||||
- fix(core): hide user tier name by @sehoon38 in
|
||||
[#17418](https://github.com/google-gemini/gemini-cli/pull/17418)
|
||||
- feat: Enforce unified folder trust for /directory add by @galz10 in
|
||||
[#17359](https://github.com/google-gemini/gemini-cli/pull/17359)
|
||||
- migrate fireToolNotificationHook to hookSystem by @ved015 in
|
||||
[#17398](https://github.com/google-gemini/gemini-cli/pull/17398)
|
||||
- Clean up dead code by @scidomino in
|
||||
[#17443](https://github.com/google-gemini/gemini-cli/pull/17443)
|
||||
- feat(workflow): add stale pull request closer with linked-issue enforcement by
|
||||
@bdmorgan in [#17449](https://github.com/google-gemini/gemini-cli/pull/17449)
|
||||
- feat(workflow): expand stale-exempt labels to include help wanted and Public
|
||||
Roadmap by @bdmorgan in
|
||||
[#17459](https://github.com/google-gemini/gemini-cli/pull/17459)
|
||||
- chore(workflow): remove redundant label-enforcer workflow by @bdmorgan in
|
||||
[#17460](https://github.com/google-gemini/gemini-cli/pull/17460)
|
||||
- Resolves the confusing error message `ripgrep exited with code null that
|
||||
occurs when a search operation is cancelled or aborted by @maximmasiutin in
|
||||
[#14267](https://github.com/google-gemini/gemini-cli/pull/14267)
|
||||
- fix: detect pnpm/pnpx in ~/.local by @rwakulszowa in
|
||||
[#15254](https://github.com/google-gemini/gemini-cli/pull/15254)
|
||||
- docs: Add instructions for MacPorts and uninstall instructions for Homebrew by
|
||||
@breun in [#17412](https://github.com/google-gemini/gemini-cli/pull/17412)
|
||||
- docs(hooks): clarify mandatory 'type' field and update hook schema
|
||||
documentation by @abhipatel12 in
|
||||
[#17499](https://github.com/google-gemini/gemini-cli/pull/17499)
|
||||
- Improve error messages on failed onboarding by @gsquared94 in
|
||||
[#17357](https://github.com/google-gemini/gemini-cli/pull/17357)
|
||||
- Follow up to "enableInteractiveShell for external tooling relying on a2a
|
||||
server" by @DavidAPierce in
|
||||
[#17130](https://github.com/google-gemini/gemini-cli/pull/17130)
|
||||
- Fix/issue 17070 by @alih552 in
|
||||
[#17242](https://github.com/google-gemini/gemini-cli/pull/17242)
|
||||
- fix(core): handle URI-encoded workspace paths in IdeClient by @dong-jun-shin
|
||||
in [#17476](https://github.com/google-gemini/gemini-cli/pull/17476)
|
||||
- feat(cli): add quick clear input shortcuts in vim mode by @harshanadim in
|
||||
[#17470](https://github.com/google-gemini/gemini-cli/pull/17470)
|
||||
- feat(core): optimize shell tool llmContent output format by @SandyTao520 in
|
||||
[#17538](https://github.com/google-gemini/gemini-cli/pull/17538)
|
||||
- Fix bug in detecting already added paths. by @jacob314 in
|
||||
[#17430](https://github.com/google-gemini/gemini-cli/pull/17430)
|
||||
- feat(scheduler): support multi-scheduler tool aggregation and nested call IDs
|
||||
by @abhipatel12 in
|
||||
[#17429](https://github.com/google-gemini/gemini-cli/pull/17429)
|
||||
- feat(agents): implement first-run experience for project-level sub-agents by
|
||||
@gundermanc in
|
||||
[#17266](https://github.com/google-gemini/gemini-cli/pull/17266)
|
||||
- Update extensions docs by @chrstnb in
|
||||
[#16093](https://github.com/google-gemini/gemini-cli/pull/16093)
|
||||
- Docs: Refactor left nav on the website by @jkcinouye in
|
||||
[#17558](https://github.com/google-gemini/gemini-cli/pull/17558)
|
||||
- fix(core): stream grep/ripgrep output to prevent OOM by @adamfweidman in
|
||||
[#17146](https://github.com/google-gemini/gemini-cli/pull/17146)
|
||||
- feat(plan): add persistent plan file storage by @jerop in
|
||||
[#17563](https://github.com/google-gemini/gemini-cli/pull/17563)
|
||||
- feat(agents): migrate subagents to event-driven scheduler by @abhipatel12 in
|
||||
[#17567](https://github.com/google-gemini/gemini-cli/pull/17567)
|
||||
- Fix extensions config error by @chrstnb in
|
||||
[#17580](https://github.com/google-gemini/gemini-cli/pull/17580)
|
||||
- fix(plan): remove subagent invocation from plan mode by @jerop in
|
||||
[#17593](https://github.com/google-gemini/gemini-cli/pull/17593)
|
||||
- feat(ui): add solid background color option for input prompt by @jacob314 in
|
||||
[#16563](https://github.com/google-gemini/gemini-cli/pull/16563)
|
||||
- feat(plan): refresh system prompt when approval mode changes (Shift+Tab) by
|
||||
@jerop in [#17585](https://github.com/google-gemini/gemini-cli/pull/17585)
|
||||
- feat(cli): add global setting to disable UI spinners by @galz10 in
|
||||
[#17234](https://github.com/google-gemini/gemini-cli/pull/17234)
|
||||
- fix(security): enforce strict policy directory permissions by @yunaseoul in
|
||||
[#17353](https://github.com/google-gemini/gemini-cli/pull/17353)
|
||||
- test(core): fix tests in windows by @scidomino in
|
||||
[#17592](https://github.com/google-gemini/gemini-cli/pull/17592)
|
||||
- feat(mcp/extensions): Allow users to selectively enable/disable MCP servers
|
||||
included in an extension( Issue #11057 & #17402) by @jasmeetsb in
|
||||
[#17434](https://github.com/google-gemini/gemini-cli/pull/17434)
|
||||
- Always map mac keys, even on other platforms by @scidomino in
|
||||
[#17618](https://github.com/google-gemini/gemini-cli/pull/17618)
|
||||
- Ctrl-O by @jacob314 in
|
||||
[#17617](https://github.com/google-gemini/gemini-cli/pull/17617)
|
||||
- feat(plan): update cycling order of approval modes by @Adib234 in
|
||||
[#17622](https://github.com/google-gemini/gemini-cli/pull/17622)
|
||||
- fix(cli): restore 'Modify with editor' option in external terminals by
|
||||
@abhipatel12 in
|
||||
[#17621](https://github.com/google-gemini/gemini-cli/pull/17621)
|
||||
- Slash command for helping in debugging by @gundermanc in
|
||||
[#17609](https://github.com/google-gemini/gemini-cli/pull/17609)
|
||||
- feat: add double-click to expand/collapse large paste placeholders by
|
||||
@jackwotherspoon in
|
||||
[#17471](https://github.com/google-gemini/gemini-cli/pull/17471)
|
||||
- refactor(cli): migrate non-interactive flow to event-driven scheduler by
|
||||
@abhipatel12 in
|
||||
[#17572](https://github.com/google-gemini/gemini-cli/pull/17572)
|
||||
- fix: loadcodeassist eligible tiers getting ignored for unlicensed users
|
||||
(regression) by @gsquared94 in
|
||||
[#17581](https://github.com/google-gemini/gemini-cli/pull/17581)
|
||||
- chore(core): delete legacy nonInteractiveToolExecutor by @abhipatel12 in
|
||||
[#17573](https://github.com/google-gemini/gemini-cli/pull/17573)
|
||||
- feat(core): enforce server prefixes for MCP tools in agent definitions by
|
||||
@abhipatel12 in
|
||||
[#17574](https://github.com/google-gemini/gemini-cli/pull/17574)
|
||||
- feat (mcp): Refresh MCP prompts on list changed notification by @MrLesk in
|
||||
[#14863](https://github.com/google-gemini/gemini-cli/pull/14863)
|
||||
- feat(ui): pretty JSON rendering tool outputs by @medic-code in
|
||||
[#9767](https://github.com/google-gemini/gemini-cli/pull/9767)
|
||||
- Fix iterm alternate buffer mode issue rendering backgrounds by @jacob314 in
|
||||
[#17634](https://github.com/google-gemini/gemini-cli/pull/17634)
|
||||
- feat(cli): add gemini extensions list --output-format=json by @AkihiroSuda in
|
||||
[#14479](https://github.com/google-gemini/gemini-cli/pull/14479)
|
||||
- fix(extensions): add .gitignore to extension templates by @godwiniheuwa in
|
||||
[#17293](https://github.com/google-gemini/gemini-cli/pull/17293)
|
||||
- paste transform followup by @jacob314 in
|
||||
[#17624](https://github.com/google-gemini/gemini-cli/pull/17624)
|
||||
- refactor: rename formatMemoryUsage to formatBytes by @Nubebuster in
|
||||
[#14997](https://github.com/google-gemini/gemini-cli/pull/14997)
|
||||
- chore: remove extra top margin from /hooks and /extensions by @jackwotherspoon
|
||||
in [#17663](https://github.com/google-gemini/gemini-cli/pull/17663)
|
||||
- feat(cli): add oncall command for issue triage by @sehoon38 in
|
||||
[#17661](https://github.com/google-gemini/gemini-cli/pull/17661)
|
||||
- Fix sidebar issue for extensions link by @chrstnb in
|
||||
[#17668](https://github.com/google-gemini/gemini-cli/pull/17668)
|
||||
- Change formatting to prevent UI redressing attacks by @scidomino in
|
||||
[#17611](https://github.com/google-gemini/gemini-cli/pull/17611)
|
||||
- Fix cluster of bugs in the settings dialog. by @jacob314 in
|
||||
[#17628](https://github.com/google-gemini/gemini-cli/pull/17628)
|
||||
- Update sidebar to resolve site build issues by @chrstnb in
|
||||
[#17674](https://github.com/google-gemini/gemini-cli/pull/17674)
|
||||
- fix(admin): fix a few bugs related to admin controls by @skeshive in
|
||||
[#17590](https://github.com/google-gemini/gemini-cli/pull/17590)
|
||||
- revert bad changes to tests by @scidomino in
|
||||
[#17673](https://github.com/google-gemini/gemini-cli/pull/17673)
|
||||
- feat(cli): show candidate issue state reason and duplicate status in triage by
|
||||
@sehoon38 in [#17676](https://github.com/google-gemini/gemini-cli/pull/17676)
|
||||
- Fix missing slash commands when Gemini CLI is in a project with a package.json
|
||||
that doesn't follow semantic versioning by @Adib234 in
|
||||
[#17561](https://github.com/google-gemini/gemini-cli/pull/17561)
|
||||
- feat(core): Model family-specific system prompts by @joshualitt in
|
||||
[#17614](https://github.com/google-gemini/gemini-cli/pull/17614)
|
||||
- Sub-agents documentation. by @gundermanc in
|
||||
[#16639](https://github.com/google-gemini/gemini-cli/pull/16639)
|
||||
- feat: wire up AskUserTool with dialog by @jackwotherspoon in
|
||||
[#17411](https://github.com/google-gemini/gemini-cli/pull/17411)
|
||||
- Load extension settings for hooks, agents, skills by @chrstnb in
|
||||
[#17245](https://github.com/google-gemini/gemini-cli/pull/17245)
|
||||
- Fix issue where Gemini CLI can make changes when simply asked a question by
|
||||
@gundermanc in
|
||||
[#17608](https://github.com/google-gemini/gemini-cli/pull/17608)
|
||||
- Update docs-writer skill for editing and add style guide for reference. by
|
||||
@g-samroberts in
|
||||
[#17669](https://github.com/google-gemini/gemini-cli/pull/17669)
|
||||
- fix(ux): have user message display a short path for pasted images by @devr0306
|
||||
in [#17613](https://github.com/google-gemini/gemini-cli/pull/17613)
|
||||
- feat(plan): enable AskUser tool in Plan mode for clarifying questions by
|
||||
@jerop in [#17694](https://github.com/google-gemini/gemini-cli/pull/17694)
|
||||
- GEMINI.md polish by @jacob314 in
|
||||
[#17680](https://github.com/google-gemini/gemini-cli/pull/17680)
|
||||
- refactor(core): centralize path validation and allow temp dir access for tools
|
||||
by @NTaylorMullen in
|
||||
[#17185](https://github.com/google-gemini/gemini-cli/pull/17185)
|
||||
- feat(skills): promote Agent Skills to stable by @abhipatel12 in
|
||||
[#17693](https://github.com/google-gemini/gemini-cli/pull/17693)
|
||||
- refactor(cli): keyboard handling and AskUserDialog by @jacob314 in
|
||||
[#17414](https://github.com/google-gemini/gemini-cli/pull/17414)
|
||||
- docs: Add Experimental Remote Agent Docs by @adamfweidman in
|
||||
[#17697](https://github.com/google-gemini/gemini-cli/pull/17697)
|
||||
- revert: promote Agent Skills to stable (#17693) by @abhipatel12 in
|
||||
[#17712](https://github.com/google-gemini/gemini-cli/pull/17712)
|
||||
- feat(ux) Expandable (ctrl-O) and scrollable approvals in alternate buffer
|
||||
mode. by @jacob314 in
|
||||
[#17640](https://github.com/google-gemini/gemini-cli/pull/17640)
|
||||
- feat(skills): promote skills settings to stable by @abhipatel12 in
|
||||
[#17713](https://github.com/google-gemini/gemini-cli/pull/17713)
|
||||
- fix(cli): Preserve settings dialog focus when searching by @SandyTao520 in
|
||||
[#17701](https://github.com/google-gemini/gemini-cli/pull/17701)
|
||||
- feat(ui): add terminal cursor support by @jacob314 in
|
||||
[#17711](https://github.com/google-gemini/gemini-cli/pull/17711)
|
||||
- docs(skills): remove experimental labels and update tutorials by @abhipatel12
|
||||
in [#17714](https://github.com/google-gemini/gemini-cli/pull/17714)
|
||||
- docs: remove 'experimental' syntax for hooks in docs by @abhipatel12 in
|
||||
[#17660](https://github.com/google-gemini/gemini-cli/pull/17660)
|
||||
- Add support for an additional exclusion file besides .gitignore and
|
||||
.geminiignore by @alisa-alisa in
|
||||
[#16487](https://github.com/google-gemini/gemini-cli/pull/16487)
|
||||
- feat: add review-frontend-and-fix command by @galz10 in
|
||||
[#17707](https://github.com/google-gemini/gemini-cli/pull/17707)
|
||||
|
||||
**Full changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.25.2...v0.26.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.26.0...v0.27.0
|
||||
|
||||
+289
-419
@@ -1,6 +1,6 @@
|
||||
# Preview release: Release v0.27.0-preview.0
|
||||
# Preview release: Release v0.28.0-preview.0
|
||||
|
||||
Released: January 27, 2026
|
||||
Released: February 3, 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,425 +13,295 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Event-Driven Architecture:** The tool execution scheduler is now
|
||||
event-driven, improving performance and reliability.
|
||||
- **System Prompt Override:** Now supports dynamic variable substitution.
|
||||
- **Rewind Command:** The `/rewind` command has been implemented.
|
||||
- **Linux Clipboard:** Image pasting capabilities for Wayland and X11 on Linux.
|
||||
- **Improved Hooks Management:** Hooks enable/disable functionality now aligns
|
||||
with skills and offers improved completion.
|
||||
- **Custom Themes for Extensions:** Extensions can now support custom themes,
|
||||
allowing for greater personalization.
|
||||
- **User Identity Display:** User identity information (auth, email, tier) is
|
||||
now displayed on startup and in the `stats` command.
|
||||
- **Plan Mode Enhancements:** Plan mode has been improved with a generic
|
||||
`Checklist` component and refactored `Todo`.
|
||||
- **Background Shell Commands:** Implementation of background shell commands.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- remove fireAgent and beforeAgent hook by @ishaanxgupta in
|
||||
[#16919](https://github.com/google-gemini/gemini-cli/pull/16919)
|
||||
- Remove unused modelHooks and toolHooks by @ved015 in
|
||||
[#17115](https://github.com/google-gemini/gemini-cli/pull/17115)
|
||||
- feat(cli): sanitize ANSI escape sequences in non-interactive output by
|
||||
@sehoon38 in [#17172](https://github.com/google-gemini/gemini-cli/pull/17172)
|
||||
- Update Attempt text to Retry when showing the retry happening to the … by
|
||||
@sehoon38 in [#17178](https://github.com/google-gemini/gemini-cli/pull/17178)
|
||||
- chore(skills): update pr-creator skill workflow by @sehoon38 in
|
||||
[#17180](https://github.com/google-gemini/gemini-cli/pull/17180)
|
||||
- feat(cli): implement event-driven tool execution scheduler by @abhipatel12 in
|
||||
[#17078](https://github.com/google-gemini/gemini-cli/pull/17078)
|
||||
- chore(release): bump version to 0.27.0-nightly.20260121.97aac696f by
|
||||
@gemini-cli-robot in
|
||||
[#17181](https://github.com/google-gemini/gemini-cli/pull/17181)
|
||||
- Remove other rewind reference in docs by @chrstnb in
|
||||
[#17149](https://github.com/google-gemini/gemini-cli/pull/17149)
|
||||
- feat(skills): add code-reviewer skill by @sehoon38 in
|
||||
[#17187](httpshttps://github.com/google-gemini/gemini-cli/pull/17187)
|
||||
- feat(plan): Extend Shift+Tab Mode Cycling to include Plan Mode by @Adib234 in
|
||||
[#17177](https://github.com/google-gemini/gemini-cli/pull/17177)
|
||||
- feat(plan): refactor TestRig and eval helper to support configurable approval
|
||||
modes by @jerop in
|
||||
[#17171](https://github.com/google-gemini/gemini-cli/pull/17171)
|
||||
- feat(workflows): support recursive workstream labeling and new IDs by
|
||||
@bdmorgan in [#17207](https://github.com/google-gemini/gemini-cli/pull/17207)
|
||||
- Run evals for all models. by @gundermanc in
|
||||
[#17123](https://github.com/google-gemini/gemini-cli/pull/17123)
|
||||
- fix(github): improve label-workstream-rollup efficiency with GraphQL by
|
||||
@bdmorgan in [#17217](https://github.com/google-gemini/gemini-cli/pull/17217)
|
||||
- Docs: Update changelogs for v.0.25.0 and v0.26.0-preview.0 releases. by
|
||||
@g-samroberts in
|
||||
[#17215](https://github.com/google-gemini/gemini-cli/pull/17215)
|
||||
- Migrate beforeTool and afterTool hooks to hookSystem by @ved015 in
|
||||
[#17204](https://github.com/google-gemini/gemini-cli/pull/17204)
|
||||
- fix(github): improve label-workstream-rollup efficiency and fix bugs by
|
||||
@bdmorgan in [#17219](https://github.com/google-gemini/gemini-cli/pull/17219)
|
||||
- feat(cli): improve skill enablement/disablement verbiage by @NTaylorMullen in
|
||||
[#17192](https://github.com/google-gemini/gemini-cli/pull/17192)
|
||||
- fix(admin): Ensure CLI commands run in non-interactive mode by @skeshive in
|
||||
[#17218](https://github.com/google-gemini/gemini-cli/pull/17218)
|
||||
- feat(core): support dynamic variable substitution in system prompt override by
|
||||
@NTaylorMullen in
|
||||
[#17042](https://github.com/google-gemini/gemini-cli/pull/17042)
|
||||
- fix(core,cli): enable recursive directory access for by @galz10 in
|
||||
[#17094](https://github.com/google-gemini/gemini-cli/pull/17094)
|
||||
- Docs: Marking for experimental features by @jkcinouye in
|
||||
[#16760](https://github.com/google-gemini/gemini-cli/pull/16760)
|
||||
- Support command/ctrl/alt backspace correctly by @scidomino in
|
||||
[#17175](https://github.com/google-gemini/gemini-cli/pull/17175)
|
||||
- feat(plan): add approval mode instructions to system prompt by @jerop in
|
||||
[#17151](https://github.com/google-gemini/gemini-cli/pull/17151)
|
||||
- feat(core): enable disableLLMCorrection by default by @SandyTao520 in
|
||||
[#17223](https://github.com/google-gemini/gemini-cli/pull/17223)
|
||||
- Remove unused slug from sidebar by @chrstnb in
|
||||
[#17229](https://github.com/google-gemini/gemini-cli/pull/17229)
|
||||
- drain stdin on exit by @scidomino in
|
||||
[#17241](https://github.com/google-gemini/gemini-cli/pull/17241)
|
||||
- refactor(cli): decouple UI from live tool execution via ToolActionsContext by
|
||||
@abhipatel12 in
|
||||
[#17183](https://github.com/google-gemini/gemini-cli/pull/17183)
|
||||
- fix(core): update token count and telemetry on /chat resume history load by
|
||||
@psinha40898 in
|
||||
[#16279](https://github.com/google-gemini/gemini-cli/pull/16279)
|
||||
- fix: /policy to display policies according to mode by @ishaanxgupta in
|
||||
[#16772](https://github.com/google-gemini/gemini-cli/pull/16772)
|
||||
- fix(core): simplify replace tool error message by @SandyTao520 in
|
||||
[#17246](https://github.com/google-gemini/gemini-cli/pull/17246)
|
||||
- feat(cli): consolidate shell inactivity and redirection monitoring by
|
||||
@NTaylorMullen in
|
||||
[#17086](https://github.com/google-gemini/gemini-cli/pull/17086)
|
||||
- fix(scheduler): prevent stale tool re-publication and fix stuck UI state by
|
||||
@abhipatel12 in
|
||||
[#17227](https://github.com/google-gemini/gemini-cli/pull/17227)
|
||||
- feat(config): default enableEventDrivenScheduler to true by @abhipatel12 in
|
||||
[#17211](https://github.com/google-gemini/gemini-cli/pull/17211)
|
||||
- feat(hooks): enable hooks system by default by @abhipatel12 in
|
||||
[#17247](https://github.com/google-gemini/gemini-cli/pull/17247)
|
||||
- feat(core): Enable AgentRegistry to track all discovered subagents by
|
||||
@SandyTao520 in
|
||||
[#17253](https://github.com/google-gemini/gemini-cli/pull/17253)
|
||||
- feat(core): Have subagents use a JSON schema type for input. by @joshualitt in
|
||||
[#17152](https://github.com/google-gemini/gemini-cli/pull/17152)
|
||||
- feat: replace large text pastes with [Pasted Text: X lines] placeholder by
|
||||
@jackwotherspoon in
|
||||
[#16422](https://github.com/google-gemini/gemini-cli/pull/16422)
|
||||
- security(hooks): Wrap hook-injected context in distinct XML tags by @yunaseoul
|
||||
in [#17237](https://github.com/google-gemini/gemini-cli/pull/17237)
|
||||
- Enable the ability to queue specific nightly eval tests by @gundermanc in
|
||||
[#17262](https://github.com/google-gemini/gemini-cli/pull/17262)
|
||||
- docs(hooks): comprehensive update of hook documentation and specs by
|
||||
@abhipatel12 in
|
||||
[#16816](https://github.com/google-gemini/gemini-cli/pull/16816)
|
||||
- refactor: improve large text paste placeholder by @jacob314 in
|
||||
[#17269](https://github.com/google-gemini/gemini-cli/pull/17269)
|
||||
- feat: implement /rewind command by @Adib234 in
|
||||
[#15720](https://github.com/google-gemini/gemini-cli/pull/15720)
|
||||
- Feature/jetbrains ide detection by @SoLoHiC in
|
||||
[#16243](https://github.com/google-gemini/gemini-cli/pull/16243)
|
||||
- docs: update typo in mcp-server.md file by @schifferl in
|
||||
[#17099](https://github.com/google-gemini/gemini-cli/pull/17099)
|
||||
- Sanitize command names and descriptions by @ehedlund in
|
||||
[#17228](https://github.com/google-gemini/gemini-cli/pull/17228)
|
||||
- fix(auth): don't crash when initial auth fails by @skeshive in
|
||||
[#17308](https://github.com/google-gemini/gemini-cli/pull/17308)
|
||||
- Added image pasting capabilities for Wayland and X11 on Linux by @devr0306 in
|
||||
[#17144](https://github.com/google-gemini/gemini-cli/pull/17144)
|
||||
- feat: add AskUser tool schema by @jackwotherspoon in
|
||||
[#16988](https://github.com/google-gemini/gemini-cli/pull/16988)
|
||||
- fix cli settings: resolve layout jitter in settings bar by @Mag1ck in
|
||||
[#16256](https://github.com/google-gemini/gemini-cli/pull/16256)
|
||||
- fix: show whitespace changes in edit tool diffs by @Ujjiyara in
|
||||
[#17213](https://github.com/google-gemini/gemini-cli/pull/17213)
|
||||
- Remove redundant calls setting linuxClipboardTool. getUserLinuxClipboardTool()
|
||||
now handles the caching internally by @jacob314 in
|
||||
[#17320](https://github.com/google-gemini/gemini-cli/pull/17320)
|
||||
- ci: allow failure in evals-nightly run step by @gundermanc in
|
||||
[#17319](https://github.com/google-gemini/gemini-cli/pull/17319)
|
||||
- feat(cli): Add state management and plumbing for agent configuration dialog by
|
||||
@SandyTao520 in
|
||||
[#17259](https://github.com/google-gemini/gemini-cli/pull/17259)
|
||||
- bug: fix ide-client connection to ide-companion when inside docker via
|
||||
ssh/devcontainer by @kapsner in
|
||||
[#15049](https://github.com/google-gemini/gemini-cli/pull/15049)
|
||||
- Emit correct newline type return by @scidomino in
|
||||
[#17331](https://github.com/google-gemini/gemini-cli/pull/17331)
|
||||
- New skill: docs-writer by @g-samroberts in
|
||||
[#17268](https://github.com/google-gemini/gemini-cli/pull/17268)
|
||||
- fix(core): Resolve AbortSignal MaxListenersExceededWarning (#5950) by
|
||||
@spencer426 in
|
||||
[#16735](https://github.com/google-gemini/gemini-cli/pull/16735)
|
||||
- Disable tips after 10 runs by @Adib234 in
|
||||
[#17101](https://github.com/google-gemini/gemini-cli/pull/17101)
|
||||
- Fix so rewind starts at the bottom and loadHistory refreshes static content.
|
||||
by @jacob314 in
|
||||
[#17335](https://github.com/google-gemini/gemini-cli/pull/17335)
|
||||
- feat(core): Remove legacy settings. by @joshualitt in
|
||||
[#17244](https://github.com/google-gemini/gemini-cli/pull/17244)
|
||||
- feat(plan): add 'communicate' tool kind by @jerop in
|
||||
[#17341](https://github.com/google-gemini/gemini-cli/pull/17341)
|
||||
- feat(routing): A/B Test Numerical Complexity Scoring for Gemini 3 by
|
||||
@mattKorwel in
|
||||
[#16041](https://github.com/google-gemini/gemini-cli/pull/16041)
|
||||
- feat(plan): update UI Theme for Plan Mode by @Adib234 in
|
||||
[#17243](https://github.com/google-gemini/gemini-cli/pull/17243)
|
||||
- fix(ui): stabilize rendering during terminal resize in alternate buffer by
|
||||
@lkk214 in [#15783](https://github.com/google-gemini/gemini-cli/pull/15783)
|
||||
- feat(cli): add /agents config command and improve agent discovery by
|
||||
@SandyTao520 in
|
||||
[#17342](https://github.com/google-gemini/gemini-cli/pull/17342)
|
||||
- feat(mcp): add enable/disable commands for MCP servers (#11057) by @jasmeetsb
|
||||
in [#16299](https://github.com/google-gemini/gemini-cli/pull/16299)
|
||||
- fix(cli)!: Default to interactive mode for positional arguments by
|
||||
@ishaanxgupta in
|
||||
[#16329](https://github.com/google-gemini/gemini-cli/pull/16329)
|
||||
- Fix issue #17080 by @jacob314 in
|
||||
[#17100](https://github.com/google-gemini/gemini-cli/pull/17100)
|
||||
- feat(core): Refresh agents after loading an extension. by @joshualitt in
|
||||
[#17355](https://github.com/google-gemini/gemini-cli/pull/17355)
|
||||
- fix(cli): include source in policy rule display by @allenhutchison in
|
||||
[#17358](https://github.com/google-gemini/gemini-cli/pull/17358)
|
||||
- fix: remove obsolete CloudCode PerDay quota and 120s terminal threshold by
|
||||
@gsquared94 in
|
||||
[#17236](https://github.com/google-gemini/gemini-cli/pull/17236)
|
||||
- Refactor subagent delegation to be one tool per agent by @gundermanc in
|
||||
[#17346](https://github.com/google-gemini/gemini-cli/pull/17346)
|
||||
- fix(core): Include MCP server name in OAuth message by @jerop in
|
||||
[#17351](https://github.com/google-gemini/gemini-cli/pull/17351)
|
||||
- Fix pr-triage.sh script to update pull requests with tags "help wanted" and
|
||||
"maintainer only" by @jacob314 in
|
||||
[#17324](https://github.com/google-gemini/gemini-cli/pull/17324)
|
||||
- feat(plan): implement simple workflow for planning in main agent by @jerop in
|
||||
[#17326](https://github.com/google-gemini/gemini-cli/pull/17326)
|
||||
- fix: exit with non-zero code when esbuild is missing by @yuvrajangadsingh in
|
||||
[#16967](https://github.com/google-gemini/gemini-cli/pull/16967)
|
||||
- fix: ensure @docs/cli/custom-commands.md UI message ordering and test by
|
||||
@medic-code in
|
||||
[#12038](https://github.com/google-gemini/gemini-cli/pull/12038)
|
||||
- fix(core): add alternative command names for Antigravity editor detec… by
|
||||
@BaeSeokJae in
|
||||
[#16829](https://github.com/google-gemini/gemini-cli/pull/16829)
|
||||
- Refactor: Migrate CLI appEvents to Core coreEvents by @Adib234 in
|
||||
[#15737](https://github.com/google-gemini/gemini-cli/pull/15737)
|
||||
- fix(core): await MCP initialization in non-interactive mode by @Ratish1 in
|
||||
[#17390](https://github.com/google-gemini/gemini-cli/pull/17390)
|
||||
- Fix modifyOtherKeys enablement on unsupported terminals by @seekskyworld in
|
||||
[#16714](https://github.com/google-gemini/gemini-cli/pull/16714)
|
||||
- fix(core): gracefully handle disk full errors in chat recording by
|
||||
@godwiniheuwa in
|
||||
[#17305](https://github.com/google-gemini/gemini-cli/pull/17305)
|
||||
- fix(oauth): update oauth to use 127.0.0.1 instead of localhost by @skeshive in
|
||||
[#17388](https://github.com/google-gemini/gemini-cli/pull/17388)
|
||||
- fix(core): use RFC 9728 compliant path-based OAuth protected resource
|
||||
discovery by @vrv in
|
||||
[#15756](https://github.com/google-gemini/gemini-cli/pull/15756)
|
||||
- Update Code Wiki README badge by @PatoBeltran in
|
||||
[#15229](https://github.com/google-gemini/gemini-cli/pull/15229)
|
||||
- Add conda installation instructions for Gemini CLI by @ishaanxgupta in
|
||||
[#16921](https://github.com/google-gemini/gemini-cli/pull/16921)
|
||||
- chore(refactor): extract BaseSettingsDialog component by @SandyTao520 in
|
||||
[#17369](https://github.com/google-gemini/gemini-cli/pull/17369)
|
||||
- fix(cli): preserve input text when declining tool approval (#15624) by
|
||||
@ManojINaik in
|
||||
[#15659](https://github.com/google-gemini/gemini-cli/pull/15659)
|
||||
- chore: upgrade dep: diff 7.0.0-> 8.0.3 by @scidomino in
|
||||
[#17403](https://github.com/google-gemini/gemini-cli/pull/17403)
|
||||
- feat: add AskUserDialog for UI component of AskUser tool by @jackwotherspoon
|
||||
in [#17344](https://github.com/google-gemini/gemini-cli/pull/17344)
|
||||
- feat(ui): display user tier in about command by @sehoon38 in
|
||||
[#17400](https://github.com/google-gemini/gemini-cli/pull/17400)
|
||||
- feat: add clearContext to AfterAgent hooks by @jackwotherspoon in
|
||||
[#16574](https://github.com/google-gemini/gemini-cli/pull/16574)
|
||||
- fix(cli): change image paste location to global temp directory (#17396) by
|
||||
@devr0306 in [#17396](https://github.com/google-gemini/gemini-cli/pull/17396)
|
||||
- Fix line endings issue with Notice file by @scidomino in
|
||||
[#17417](https://github.com/google-gemini/gemini-cli/pull/17417)
|
||||
- feat(plan): implement persistent approvalMode setting by @Adib234 in
|
||||
[#17350](https://github.com/google-gemini/gemini-cli/pull/17350)
|
||||
- feat(ui): Move keyboard handling into BaseSettingsDialog by @SandyTao520 in
|
||||
[#17404](https://github.com/google-gemini/gemini-cli/pull/17404)
|
||||
- Allow prompt queueing during MCP initialization by @Adib234 in
|
||||
[#17395](https://github.com/google-gemini/gemini-cli/pull/17395)
|
||||
- feat: implement AgentConfigDialog for /agents config command by @SandyTao520
|
||||
in [#17370](https://github.com/google-gemini/gemini-cli/pull/17370)
|
||||
- fix(agents): default to all tools when tool list is omitted in subagents by
|
||||
@gundermanc in
|
||||
[#17422](https://github.com/google-gemini/gemini-cli/pull/17422)
|
||||
- feat(cli): Moves tool confirmations to a queue UX by @abhipatel12 in
|
||||
[#17276](https://github.com/google-gemini/gemini-cli/pull/17276)
|
||||
- fix(core): hide user tier name by @sehoon38 in
|
||||
[#17418](https://github.com/google-gemini/gemini-cli/pull/17418)
|
||||
- feat: Enforce unified folder trust for /directory add by @galz10 in
|
||||
[#17359](https://github.com/google-gemini/gemini-cli/pull/17359)
|
||||
- migrate fireToolNotificationHook to hookSystem by @ved015 in
|
||||
[#17398](https://github.com/google-gemini/gemini-cli/pull/17398)
|
||||
- Clean up dead code by @scidomino in
|
||||
[#17443](https://github.com/google-gemini/gemini-cli/pull/17443)
|
||||
- feat(workflow): add stale pull request closer with linked-issue enforcement by
|
||||
@bdmorgan in [#17449](https://github.com/google-gemini/gemini-cli/pull/17449)
|
||||
- feat(workflow): expand stale-exempt labels to include help wanted and Public
|
||||
Roadmap by @bdmorgan in
|
||||
[#17459](https://github.com/google-gemini/gemini-cli/pull/17459)
|
||||
- chore(workflow): remove redundant label-enforcer workflow by @bdmorgan in
|
||||
[#17460](https://github.com/google-gemini/gemini-cli/pull/17460)
|
||||
- Resolves the confusing error message `ripgrep exited with code null that
|
||||
occurs when a search operation is cancelled or aborted by @maximmasiutin in
|
||||
[#14267](https://github.com/google-gemini/gemini-cli/pull/14267)
|
||||
- fix: detect pnpm/pnpx in ~/.local by @rwakulszowa in
|
||||
[#15254](https://github.com/google-gemini/gemini-cli/pull/15254)
|
||||
- docs: Add instructions for MacPorts and uninstall instructions for Homebrew by
|
||||
@breun in [#17412](https://github.com/google-gemini/gemini-cli/pull/17412)
|
||||
- docs(hooks): clarify mandatory 'type' field and update hook schema
|
||||
documentation by @abhipatel12 in
|
||||
[#17499](https://github.com/google-gemini/gemini-cli/pull/17499)
|
||||
- Improve error messages on failed onboarding by @gsquared94 in
|
||||
[#17357](https://github.com/google-gemini/gemini-cli/pull/17357)
|
||||
- Follow up to "enableInteractiveShell for external tooling relying on a2a
|
||||
server" by @DavidAPierce in
|
||||
[#17130](https://github.com/google-gemini/gemini-cli/pull/17130)
|
||||
- Fix/issue 17070 by @alih552 in
|
||||
[#17242](https://github.com/google-gemini/gemini-cli/pull/17242)
|
||||
- fix(core): handle URI-encoded workspace paths in IdeClient by @dong-jun-shin
|
||||
in [#17476](https://github.com/google-gemini/gemini-cli/pull/17476)
|
||||
- feat(cli): add quick clear input shortcuts in vim mode by @harshanadim in
|
||||
[#17470](https://github.com/google-gemini/gemini-cli/pull/17470)
|
||||
- feat(core): optimize shell tool llmContent output format by @SandyTao520 in
|
||||
[#17538](https://github.com/google-gemini/gemini-cli/pull/17538)
|
||||
- Fix bug in detecting already added paths. by @jacob314 in
|
||||
[#17430](https://github.com/google-gemini/gemini-cli/pull/17430)
|
||||
- feat(scheduler): support multi-scheduler tool aggregation and nested call IDs
|
||||
by @abhipatel12 in
|
||||
[#17429](https://github.com/google-gemini/gemini-cli/pull/17429)
|
||||
- feat(agents): implement first-run experience for project-level sub-agents by
|
||||
@gundermanc in
|
||||
[#17266](https://github.com/google-gemini/gemini-cli/pull/17266)
|
||||
- Update extensions docs by @chrstnb in
|
||||
[#16093](https://github.com/google-gemini/gemini-cli/pull/16093)
|
||||
- Docs: Refactor left nav on the website by @jkcinouye in
|
||||
[#17558](https://github.com/google-gemini/gemini-cli/pull/17558)
|
||||
- fix(core): stream grep/ripgrep output to prevent OOM by @adamfweidman in
|
||||
[#17146](https://github.com/google-gemini/gemini-cli/pull/17146)
|
||||
- feat(plan): add persistent plan file storage by @jerop in
|
||||
[#17563](https://github.com/google-gemini/gemini-cli/pull/17563)
|
||||
- feat(agents): migrate subagents to event-driven scheduler by @abhipatel12 in
|
||||
[#17567](https://github.com/google-gemini/gemini-cli/pull/17567)
|
||||
- Fix extensions config error by @chrstnb in
|
||||
[#17580](https://github.com/google-gemini/gemini-cli/pull/17580)
|
||||
- fix(plan): remove subagent invocation from plan mode by @jerop in
|
||||
[#17593](https://github.com/google-gemini/gemini-cli/pull/17593)
|
||||
- feat(ui): add solid background color option for input prompt by @jacob314 in
|
||||
[#16563](https://github.com/google-gemini/gemini-cli/pull/16563)
|
||||
- feat(plan): refresh system prompt when approval mode changes (Shift+Tab) by
|
||||
@jerop in [#17585](https://github.com/google-gemini/gemini-cli/pull/17585)
|
||||
- feat(cli): add global setting to disable UI spinners by @galz10 in
|
||||
[#17234](https://github.com/google-gemini/gemini-cli/pull/17234)
|
||||
- fix(security): enforce strict policy directory permissions by @yunaseoul in
|
||||
[#17353](https://github.com/google-gemini/gemini-cli/pull/17353)
|
||||
- test(core): fix tests in windows by @scidomino in
|
||||
[#17592](https://github.com/google-gemini/gemini-cli/pull/17592)
|
||||
- feat(mcp/extensions): Allow users to selectively enable/disable MCP servers
|
||||
included in an extension( Issue #11057 & #17402) by @jasmeetsb in
|
||||
[#17434](https://github.com/google-gemini/gemini-cli/pull/17434)
|
||||
- Always map mac keys, even on other platforms by @scidomino in
|
||||
[#17618](https://github.com/google-gemini/gemini-cli/pull/17618)
|
||||
- Ctrl-O by @jacob314 in
|
||||
[#17617](https://github.com/google-gemini/gemini-cli/pull/17617)
|
||||
- feat(plan): update cycling order of approval modes by @Adib234 in
|
||||
[#17622](https://github.com/google-gemini/gemini-cli/pull/17622)
|
||||
- fix(cli): restore 'Modify with editor' option in external terminals by
|
||||
@abhipatel12 in
|
||||
[#17621](https://github.com/google-gemini/gemini-cli/pull/17621)
|
||||
- Slash command for helping in debugging by @gundermanc in
|
||||
[#17609](https://github.com/google-gemini/gemini-cli/pull/17609)
|
||||
- feat: add double-click to expand/collapse large paste placeholders by
|
||||
@jackwotherspoon in
|
||||
[#17471](https://github.com/google-gemini/gemini-cli/pull/17471)
|
||||
- refactor(cli): migrate non-interactive flow to event-driven scheduler by
|
||||
@abhipatel12 in
|
||||
[#17572](https://github.com/google-gemini/gemini-cli/pull/17572)
|
||||
- fix: loadcodeassist eligible tiers getting ignored for unlicensed users
|
||||
(regression) by @gsquared94 in
|
||||
[#17581](https://github.com/google-gemini/gemini-cli/pull/17581)
|
||||
- chore(core): delete legacy nonInteractiveToolExecutor by @abhipatel12 in
|
||||
[#17573](https://github.com/google-gemini/gemini-cli/pull/17573)
|
||||
- feat(core): enforce server prefixes for MCP tools in agent definitions by
|
||||
@abhipatel12 in
|
||||
[#17574](https://github.com/google-gemini/gemini-cli/pull/17574)
|
||||
- feat (mcp): Refresh MCP prompts on list changed notification by @MrLesk in
|
||||
[#14863](https://github.com/google-gemini/gemini-cli/pull/14863)
|
||||
- feat(ui): pretty JSON rendering tool outputs by @medic-code in
|
||||
[#9767](https://github.com/google-gemini/gemini-cli/pull/9767)
|
||||
- Fix iterm alternate buffer mode issue rendering backgrounds by @jacob314 in
|
||||
[#17634](https://github.com/google-gemini/gemini-cli/pull/17634)
|
||||
- feat(cli): add gemini extensions list --output-format=json by @AkihiroSuda in
|
||||
[#14479](https://github.com/google-gemini/gemini-cli/pull/14479)
|
||||
- fix(extensions): add .gitignore to extension templates by @godwiniheuwa in
|
||||
[#17293](https://github.com/google-gemini/gemini-cli/pull/17293)
|
||||
- paste transform followup by @jacob314 in
|
||||
[#17624](https://github.com/google-gemini/gemini-cli/pull/17624)
|
||||
- refactor: rename formatMemoryUsage to formatBytes by @Nubebuster in
|
||||
[#14997](https://github.com/google-gemini/gemini-cli/pull/14997)
|
||||
- chore: remove extra top margin from /hooks and /extensions by @jackwotherspoon
|
||||
in [#17663](https://github.com/google-gemini/gemini-cli/pull/17663)
|
||||
- feat(cli): add oncall command for issue triage by @sehoon38 in
|
||||
[#17661](https://github.com/google-gemini/gemini-cli/pull/17661)
|
||||
- Fix sidebar issue for extensions link by @chrstnb in
|
||||
[#17668](https://github.com/google-gemini/gemini-cli/pull/17668)
|
||||
- Change formatting to prevent UI redressing attacks by @scidomino in
|
||||
[#17611](https://github.com/google-gemini/gemini-cli/pull/17611)
|
||||
- Fix cluster of bugs in the settings dialog. by @jacob314 in
|
||||
[#17628](https://github.com/google-gemini/gemini-cli/pull/17628)
|
||||
- Update sidebar to resolve site build issues by @chrstnb in
|
||||
[#17674](https://github.com/google-gemini/gemini-cli/pull/17674)
|
||||
- fix(admin): fix a few bugs related to admin controls by @skeshive in
|
||||
[#17590](https://github.com/google-gemini/gemini-cli/pull/17590)
|
||||
- revert bad changes to tests by @scidomino in
|
||||
[#17673](https://github.com/google-gemini/gemini-cli/pull/17673)
|
||||
- feat(cli): show candidate issue state reason and duplicate status in triage by
|
||||
@sehoon38 in [#17676](https://github.com/google-gemini/gemini-cli/pull/17676)
|
||||
- Fix missing slash commands when Gemini CLI is in a project with a package.json
|
||||
that doesn't follow semantic versioning by @Adib234 in
|
||||
[#17561](https://github.com/google-gemini/gemini-cli/pull/17561)
|
||||
- feat(core): Model family-specific system prompts by @joshualitt in
|
||||
[#17614](https://github.com/google-gemini/gemini-cli/pull/17614)
|
||||
- Sub-agents documentation. by @gundermanc in
|
||||
[#16639](https://github.com/google-gemini/gemini-cli/pull/16639)
|
||||
- feat: wire up AskUserTool with dialog by @jackwotherspoon in
|
||||
[#17411](https://github.com/google-gemini/gemini-cli/pull/17411)
|
||||
- Load extension settings for hooks, agents, skills by @chrstnb in
|
||||
[#17245](https://github.com/google-gemini/gemini-cli/pull/17245)
|
||||
- Fix issue where Gemini CLI can make changes when simply asked a question by
|
||||
@gundermanc in
|
||||
[#17608](https://github.com/google-gemini/gemini-cli/pull/17608)
|
||||
- Update docs-writer skill for editing and add style guide for reference. by
|
||||
@g-samroberts in
|
||||
[#17669](https://github.com/google-gemini/gemini-cli/pull/17669)
|
||||
- fix(ux): have user message display a short path for pasted images by @devr0306
|
||||
in [#17613](https://github.com/google-gemini/gemini-cli/pull/17613)
|
||||
- feat(plan): enable AskUser tool in Plan mode for clarifying questions by
|
||||
@jerop in [#17694](https://github.com/google-gemini/gemini-cli/pull/17694)
|
||||
- GEMINI.md polish by @jacob314 in
|
||||
[#17680](https://github.com/google-gemini/gemini-cli/pull/17680)
|
||||
- refactor(core): centralize path validation and allow temp dir access for tools
|
||||
by @NTaylorMullen in
|
||||
[#17185](https://github.com/google-gemini/gemini-cli/pull/17185)
|
||||
- feat(skills): promote Agent Skills to stable by @abhipatel12 in
|
||||
[#17693](https://github.com/google-gemini/gemini-cli/pull/17693)
|
||||
- refactor(cli): keyboard handling and AskUserDialog by @jacob314 in
|
||||
[#17414](https://github.com/google-gemini/gemini-cli/pull/17414)
|
||||
- docs: Add Experimental Remote Agent Docs by @adamfweidman in
|
||||
[#17697](https://github.com/google-gemini/gemini-cli/pull/17697)
|
||||
- revert: promote Agent Skills to stable (#17693) by @abhipatel12 in
|
||||
[#17712](https://github.com/google-gemini/gemini-cli/pull/17712)
|
||||
- feat(ux) Expandable (ctrl-O) and scrollable approvals in alternate buffer
|
||||
mode. by @jacob314 in
|
||||
[#17640](https://github.com/google-gemini/gemini-cli/pull/17640)
|
||||
- feat(skills): promote skills settings to stable by @abhipatel12 in
|
||||
[#17713](https://github.com/google-gemini/gemini-cli/pull/17713)
|
||||
- fix(cli): Preserve settings dialog focus when searching by @SandyTao520 in
|
||||
[#17701](https://github.com/google-gemini/gemini-cli/pull/17701)
|
||||
- feat(ui): add terminal cursor support by @jacob314 in
|
||||
[#17711](https://github.com/google-gemini/gemini-cli/pull/17711)
|
||||
- docs(skills): remove experimental labels and update tutorials by @abhipatel12
|
||||
in [#17714](https://github.com/google-gemini/gemini-cli/pull/17714)
|
||||
- docs: remove 'experimental' syntax for hooks in docs by @abhipatel12 in
|
||||
[#17660](https://github.com/google-gemini/gemini-cli/pull/17660)
|
||||
- Add support for an additional exclusion file besides .gitignore and
|
||||
.geminiignore by @alisa-alisa in
|
||||
[#16487](https://github.com/google-gemini/gemini-cli/pull/16487)
|
||||
- feat: add review-frontend-and-fix command by @galz10 in
|
||||
[#17707](https://github.com/google-gemini/gemini-cli/pull/17707)
|
||||
- feat(commands): add /prompt-suggest slash command by NTaylorMullen in
|
||||
[#17264](https://github.com/google-gemini/gemini-cli/pull/17264)
|
||||
- feat(cli): align hooks enable/disable with skills and improve completion by
|
||||
sehoon38 in [#16822](https://github.com/google-gemini/gemini-cli/pull/16822)
|
||||
- docs: add CLI reference documentation by leochiu-a in
|
||||
[#17504](https://github.com/google-gemini/gemini-cli/pull/17504)
|
||||
- chore(release): bump version to 0.28.0-nightly.20260128.adc8e11bb by
|
||||
gemini-cli-robot in
|
||||
[#17725](https://github.com/google-gemini/gemini-cli/pull/17725)
|
||||
- feat(skills): final stable promotion cleanup by abhipatel12 in
|
||||
[#17726](https://github.com/google-gemini/gemini-cli/pull/17726)
|
||||
- test(core): mock fetch in OAuth transport fallback tests by jw409 in
|
||||
[#17059](https://github.com/google-gemini/gemini-cli/pull/17059)
|
||||
- feat(cli): include auth method in /bug by erikus in
|
||||
[#17569](https://github.com/google-gemini/gemini-cli/pull/17569)
|
||||
- Add a email privacy note to bug_report template by nemyung in
|
||||
[#17474](https://github.com/google-gemini/gemini-cli/pull/17474)
|
||||
- Rewind documentation by Adib234 in
|
||||
[#17446](https://github.com/google-gemini/gemini-cli/pull/17446)
|
||||
- fix: verify audio/video MIME types with content check by maru0804 in
|
||||
[#16907](https://github.com/google-gemini/gemini-cli/pull/16907)
|
||||
- feat(core): add support for positron ide (#15045) by kapsner in
|
||||
[#15047](https://github.com/google-gemini/gemini-cli/pull/15047)
|
||||
- /oncall dedup - wrap texts to nextlines by sehoon38 in
|
||||
[#17782](https://github.com/google-gemini/gemini-cli/pull/17782)
|
||||
- fix(admin): rename advanced features admin setting by skeshive in
|
||||
[#17786](https://github.com/google-gemini/gemini-cli/pull/17786)
|
||||
- [extension config] Make breaking optional value non-optional by chrstnb in
|
||||
[#17785](https://github.com/google-gemini/gemini-cli/pull/17785)
|
||||
- Fix docs-writer skill issues by g-samroberts in
|
||||
[#17734](https://github.com/google-gemini/gemini-cli/pull/17734)
|
||||
- fix(core): suppress duplicate hook failure warnings during streaming by
|
||||
abhipatel12 in
|
||||
[#17727](https://github.com/google-gemini/gemini-cli/pull/17727)
|
||||
- test: add more tests for AskUser by jackwotherspoon in
|
||||
[#17720](https://github.com/google-gemini/gemini-cli/pull/17720)
|
||||
- feat(cli): enable activity logging for non-interactive mode and evals by
|
||||
SandyTao520 in
|
||||
[#17703](https://github.com/google-gemini/gemini-cli/pull/17703)
|
||||
- feat(core): add support for custom deny messages in policy rules by
|
||||
allenhutchison in
|
||||
[#17427](https://github.com/google-gemini/gemini-cli/pull/17427)
|
||||
- Fix unintended credential exposure to MCP Servers by Adib234 in
|
||||
[#17311](https://github.com/google-gemini/gemini-cli/pull/17311)
|
||||
- feat(extensions): add support for custom themes in extensions by spencer426 in
|
||||
[#17327](https://github.com/google-gemini/gemini-cli/pull/17327)
|
||||
- fix: persist and restore workspace directories on session resume by
|
||||
korade-krushna in
|
||||
[#17454](https://github.com/google-gemini/gemini-cli/pull/17454)
|
||||
- Update release notes pages for 0.26.0 and 0.27.0-preview. by g-samroberts in
|
||||
[#17744](https://github.com/google-gemini/gemini-cli/pull/17744)
|
||||
- feat(ux): update cell border color and created test file for table rendering
|
||||
by devr0306 in
|
||||
[#17798](https://github.com/google-gemini/gemini-cli/pull/17798)
|
||||
- Change height for the ToolConfirmationQueue. by jacob314 in
|
||||
[#17799](https://github.com/google-gemini/gemini-cli/pull/17799)
|
||||
- feat(cli): add user identity info to stats command by sehoon38 in
|
||||
[#17612](https://github.com/google-gemini/gemini-cli/pull/17612)
|
||||
- fix(ux): fixed off-by-some wrapping caused by fixed-width characters by
|
||||
devr0306 in [#17816](https://github.com/google-gemini/gemini-cli/pull/17816)
|
||||
- feat(cli): update undo/redo keybindings to Cmd+Z/Alt+Z and
|
||||
Shift+Cmd+Z/Shift+Alt+Z by scidomino in
|
||||
[#17800](https://github.com/google-gemini/gemini-cli/pull/17800)
|
||||
- fix(evals): use absolute path for activity log directory by SandyTao520 in
|
||||
[#17830](https://github.com/google-gemini/gemini-cli/pull/17830)
|
||||
- test: add integration test to verify stdout/stderr routing by ved015 in
|
||||
[#17280](https://github.com/google-gemini/gemini-cli/pull/17280)
|
||||
- fix(cli): list installed extensions when update target missing by tt-a1i in
|
||||
[#17082](https://github.com/google-gemini/gemini-cli/pull/17082)
|
||||
- fix(cli): handle PAT tokens and credentials in git remote URL parsing by
|
||||
afarber in [#14650](https://github.com/google-gemini/gemini-cli/pull/14650)
|
||||
- fix(core): use returnDisplay for error result display by Nubebuster in
|
||||
[#14994](https://github.com/google-gemini/gemini-cli/pull/14994)
|
||||
- Fix detection of bun as package manager by Randomblock1 in
|
||||
[#17462](https://github.com/google-gemini/gemini-cli/pull/17462)
|
||||
- feat(cli): show hooksConfig.enabled in settings dialog by abhipatel12 in
|
||||
[#17810](https://github.com/google-gemini/gemini-cli/pull/17810)
|
||||
- feat(cli): Display user identity (auth, email, tier) on startup by yunaseoul
|
||||
in [#17591](https://github.com/google-gemini/gemini-cli/pull/17591)
|
||||
- fix: prevent ghost border for AskUserDialog by jackwotherspoon in
|
||||
[#17788](https://github.com/google-gemini/gemini-cli/pull/17788)
|
||||
- docs: mark A2A subagents as experimental in subagents.md by adamfweidman in
|
||||
[#17863](https://github.com/google-gemini/gemini-cli/pull/17863)
|
||||
- Resolve error thrown for sensitive values by chrstnb in
|
||||
[#17826](https://github.com/google-gemini/gemini-cli/pull/17826)
|
||||
- fix(admin): Rename secureModeEnabled to strictModeDisabled by skeshive in
|
||||
[#17789](https://github.com/google-gemini/gemini-cli/pull/17789)
|
||||
- feat(ux): update truncate dots to be shorter in tables by devr0306 in
|
||||
[#17825](https://github.com/google-gemini/gemini-cli/pull/17825)
|
||||
- fix(core): resolve DEP0040 punycode deprecation via patch-package by
|
||||
ATHARVA262005 in
|
||||
[#17692](https://github.com/google-gemini/gemini-cli/pull/17692)
|
||||
- feat(plan): create generic Checklist component and refactor Todo by Adib234 in
|
||||
[#17741](https://github.com/google-gemini/gemini-cli/pull/17741)
|
||||
- Cleanup post delegate_to_agent removal by gundermanc in
|
||||
[#17875](https://github.com/google-gemini/gemini-cli/pull/17875)
|
||||
- fix(core): use GIT_CONFIG_GLOBAL to isolate shadow git repo configuration -
|
||||
Fixes #17877 by cocosheng-g in
|
||||
[#17803](https://github.com/google-gemini/gemini-cli/pull/17803)
|
||||
- Disable mouse tracking e2e by alisa-alisa in
|
||||
[#17880](https://github.com/google-gemini/gemini-cli/pull/17880)
|
||||
- fix(cli): use correct setting key for Cloud Shell auth by sehoon38 in
|
||||
[#17884](https://github.com/google-gemini/gemini-cli/pull/17884)
|
||||
- chore: revert IDE specific ASCII logo by jackwotherspoon in
|
||||
[#17887](https://github.com/google-gemini/gemini-cli/pull/17887)
|
||||
- Revert "fix(core): resolve DEP0040 punycode deprecation via patch-package" by
|
||||
sehoon38 in [#17898](https://github.com/google-gemini/gemini-cli/pull/17898)
|
||||
- Refactoring of disabling of mouse tracking in e2e tests by alisa-alisa in
|
||||
[#17902](https://github.com/google-gemini/gemini-cli/pull/17902)
|
||||
- feat(core): Add GOOGLE_GENAI_API_VERSION environment variable support by deyim
|
||||
in [#16177](https://github.com/google-gemini/gemini-cli/pull/16177)
|
||||
- feat(core): Isolate and cleanup truncated tool outputs by SandyTao520 in
|
||||
[#17594](https://github.com/google-gemini/gemini-cli/pull/17594)
|
||||
- Create skills page, update commands, refine docs by g-samroberts in
|
||||
[#17842](https://github.com/google-gemini/gemini-cli/pull/17842)
|
||||
- feat: preserve EOL in files by Thomas-Shephard in
|
||||
[#16087](https://github.com/google-gemini/gemini-cli/pull/16087)
|
||||
- Fix HalfLinePaddedBox in screenreader mode. by jacob314 in
|
||||
[#17914](https://github.com/google-gemini/gemini-cli/pull/17914)
|
||||
- bug(ux) vim mode fixes. Start in insert mode. Fix bug blocking F12 and ctrl-X
|
||||
in vim mode. by jacob314 in
|
||||
[#17938](https://github.com/google-gemini/gemini-cli/pull/17938)
|
||||
- feat(core): implement interactive and non-interactive consent for OAuth by
|
||||
ehedlund in [#17699](https://github.com/google-gemini/gemini-cli/pull/17699)
|
||||
- perf(core): optimize token calculation and add support for multimodal tool
|
||||
responses by abhipatel12 in
|
||||
[#17835](https://github.com/google-gemini/gemini-cli/pull/17835)
|
||||
- refactor(hooks): remove legacy tools.enableHooks setting by abhipatel12 in
|
||||
[#17867](https://github.com/google-gemini/gemini-cli/pull/17867)
|
||||
- feat(ci): add npx smoke test to verify installability by bdmorgan in
|
||||
[#17927](https://github.com/google-gemini/gemini-cli/pull/17927)
|
||||
- feat(core): implement dynamic policy registration for subagents by abhipatel12
|
||||
in [#17838](https://github.com/google-gemini/gemini-cli/pull/17838)
|
||||
- feat: Implement background shell commands by galz10 in
|
||||
[#14849](https://github.com/google-gemini/gemini-cli/pull/14849)
|
||||
- feat(admin): provide actionable error messages for disabled features by
|
||||
skeshive in [#17815](https://github.com/google-gemini/gemini-cli/pull/17815)
|
||||
- Fix bugs where Rewind and Resume showed Ugly and 100X too verbose content. by
|
||||
jacob314 in [#17940](https://github.com/google-gemini/gemini-cli/pull/17940)
|
||||
- Fix broken link in docs by chrstnb in
|
||||
[#17959](https://github.com/google-gemini/gemini-cli/pull/17959)
|
||||
- feat(plan): reuse standard tool confirmation for AskUser tool by jerop in
|
||||
[#17864](https://github.com/google-gemini/gemini-cli/pull/17864)
|
||||
- feat(core): enable overriding CODE_ASSIST_API_VERSION with env var by
|
||||
lottielin in [#17942](https://github.com/google-gemini/gemini-cli/pull/17942)
|
||||
- run npx pointing to the specific commit SHA by sehoon38 in
|
||||
[#17970](https://github.com/google-gemini/gemini-cli/pull/17970)
|
||||
- Add allowedExtensions setting by kevinjwang1 in
|
||||
[#17695](https://github.com/google-gemini/gemini-cli/pull/17695)
|
||||
- feat(plan): refactor ToolConfirmationPayload to union type by jerop in
|
||||
[#17980](https://github.com/google-gemini/gemini-cli/pull/17980)
|
||||
- lower the default max retries to reduce contention by sehoon38 in
|
||||
[#17975](https://github.com/google-gemini/gemini-cli/pull/17975)
|
||||
- fix(core): ensure YOLO mode auto-approves complex shell commands when parsing
|
||||
fails by abhipatel12 in
|
||||
[#17920](https://github.com/google-gemini/gemini-cli/pull/17920)
|
||||
- Fix broken link. by g-samroberts in
|
||||
[#17972](https://github.com/google-gemini/gemini-cli/pull/17972)
|
||||
- Support ctrl-C and Ctrl-D correctly Refactor so InputPrompt has priority over
|
||||
AppContainer for input handling. by jacob314 in
|
||||
[#17993](https://github.com/google-gemini/gemini-cli/pull/17993)
|
||||
- Fix truncation for AskQuestion by jacob314 in
|
||||
[#18001](https://github.com/google-gemini/gemini-cli/pull/18001)
|
||||
- fix(workflow): update maintainer check logic to be inclusive and
|
||||
case-insensitive by bdmorgan in
|
||||
[#18009](https://github.com/google-gemini/gemini-cli/pull/18009)
|
||||
- Fix Esc cancel during streaming by LyalinDotCom in
|
||||
[#18039](https://github.com/google-gemini/gemini-cli/pull/18039)
|
||||
- feat(acp): add session resume support by bdmorgan in
|
||||
[#18043](https://github.com/google-gemini/gemini-cli/pull/18043)
|
||||
- fix(ci): prevent stale PR closer from incorrectly closing new PRs by bdmorgan
|
||||
in [#18069](https://github.com/google-gemini/gemini-cli/pull/18069)
|
||||
- chore: delete autoAccept setting unused in production by victorvianna in
|
||||
[#17862](https://github.com/google-gemini/gemini-cli/pull/17862)
|
||||
- feat(plan): use placeholder for choice question "Other" option by jerop in
|
||||
[#18101](https://github.com/google-gemini/gemini-cli/pull/18101)
|
||||
- docs: update clearContext to hookSpecificOutput by jackwotherspoon in
|
||||
[#18024](https://github.com/google-gemini/gemini-cli/pull/18024)
|
||||
- docs-writer skill: Update docs writer skill by jkcinouye in
|
||||
[#17928](https://github.com/google-gemini/gemini-cli/pull/17928)
|
||||
- Sehoon/oncall filter by sehoon38 in
|
||||
[#18105](https://github.com/google-gemini/gemini-cli/pull/18105)
|
||||
- feat(core): add setting to disable loop detection by SandyTao520 in
|
||||
[#18008](https://github.com/google-gemini/gemini-cli/pull/18008)
|
||||
- Docs: Revise docs/index.md by jkcinouye in
|
||||
[#17879](https://github.com/google-gemini/gemini-cli/pull/17879)
|
||||
- Fix up/down arrow regression and add test. by jacob314 in
|
||||
[#18108](https://github.com/google-gemini/gemini-cli/pull/18108)
|
||||
- fix(ui): prevent content leak in MaxSizedBox bottom overflow by jerop in
|
||||
[#17991](https://github.com/google-gemini/gemini-cli/pull/17991)
|
||||
- refactor: migrate checks.ts utility to core and deduplicate by jerop in
|
||||
[#18139](https://github.com/google-gemini/gemini-cli/pull/18139)
|
||||
- feat(core): implement tool name aliasing for backward compatibility by
|
||||
SandyTao520 in
|
||||
[#17974](https://github.com/google-gemini/gemini-cli/pull/17974)
|
||||
- docs: fix help-wanted label spelling by pavan-sh in
|
||||
[#18114](https://github.com/google-gemini/gemini-cli/pull/18114)
|
||||
- feat(cli): implement automatic theme switching based on terminal background by
|
||||
Abhijit-2592 in
|
||||
[#17976](https://github.com/google-gemini/gemini-cli/pull/17976)
|
||||
- fix(ide): no-op refactoring that moves the connection logic to helper
|
||||
functions by skeshive in
|
||||
[#18118](https://github.com/google-gemini/gemini-cli/pull/18118)
|
||||
- feat: update review-frontend-and-fix slash command to review-and-fix by galz10
|
||||
in [#18146](https://github.com/google-gemini/gemini-cli/pull/18146)
|
||||
- fix: improve Ctrl+R reverse search by jackwotherspoon in
|
||||
[#18075](https://github.com/google-gemini/gemini-cli/pull/18075)
|
||||
- feat(plan): handle inconsistency in schedulers by Adib234 in
|
||||
[#17813](https://github.com/google-gemini/gemini-cli/pull/17813)
|
||||
- feat(plan): add core logic and exit_plan_mode tool definition by jerop in
|
||||
[#18110](https://github.com/google-gemini/gemini-cli/pull/18110)
|
||||
- feat(core): rename search_file_content tool to grep_search and add legacy
|
||||
alias by SandyTao520 in
|
||||
[#18003](https://github.com/google-gemini/gemini-cli/pull/18003)
|
||||
- fix(core): prioritize detailed error messages for code assist setup by
|
||||
gsquared94 in [#17852](https://github.com/google-gemini/gemini-cli/pull/17852)
|
||||
- fix(cli): resolve environment loading and auth validation issues in ACP mode
|
||||
by bdmorgan in
|
||||
[#18025](https://github.com/google-gemini/gemini-cli/pull/18025)
|
||||
- feat(core): add .agents/skills directory alias for skill discovery by
|
||||
NTaylorMullen in
|
||||
[#18151](https://github.com/google-gemini/gemini-cli/pull/18151)
|
||||
- chore(core): reassign telemetry keys to avoid server conflict by mattKorwel in
|
||||
[#18161](https://github.com/google-gemini/gemini-cli/pull/18161)
|
||||
- Add link to rewind doc in commands.md by Adib234 in
|
||||
[#17961](https://github.com/google-gemini/gemini-cli/pull/17961)
|
||||
- feat(core): add draft-2020-12 JSON Schema support with lenient fallback by
|
||||
afarber in [#15060](https://github.com/google-gemini/gemini-cli/pull/15060)
|
||||
- refactor(core): robust trimPreservingTrailingNewline and regression test by
|
||||
adamfweidman in
|
||||
[#18196](https://github.com/google-gemini/gemini-cli/pull/18196)
|
||||
- Remove MCP servers on extension uninstall by chrstnb in
|
||||
[#18121](https://github.com/google-gemini/gemini-cli/pull/18121)
|
||||
- refactor: localize ACP error parsing logic to cli package by bdmorgan in
|
||||
[#18193](https://github.com/google-gemini/gemini-cli/pull/18193)
|
||||
- feat(core): Add A2A auth config types by adamfweidman in
|
||||
[#18205](https://github.com/google-gemini/gemini-cli/pull/18205)
|
||||
- Set default max attempts to 3 and use the common variable by sehoon38 in
|
||||
[#18209](https://github.com/google-gemini/gemini-cli/pull/18209)
|
||||
- feat(plan): add exit_plan_mode ui and prompt by jerop in
|
||||
[#18162](https://github.com/google-gemini/gemini-cli/pull/18162)
|
||||
- fix(test): improve test isolation and enable subagent evaluations by
|
||||
cocosheng-g in
|
||||
[#18138](https://github.com/google-gemini/gemini-cli/pull/18138)
|
||||
- feat(plan): use custom deny messages in plan mode policies by Adib234 in
|
||||
[#18195](https://github.com/google-gemini/gemini-cli/pull/18195)
|
||||
- Match on extension ID when stopping extensions by chrstnb in
|
||||
[#18218](https://github.com/google-gemini/gemini-cli/pull/18218)
|
||||
- fix(core): Respect user's .gitignore preference by xyrolle in
|
||||
[#15482](https://github.com/google-gemini/gemini-cli/pull/15482)
|
||||
- docs: document GEMINI_CLI_HOME environment variable by adamfweidman in
|
||||
[#18219](https://github.com/google-gemini/gemini-cli/pull/18219)
|
||||
- chore(core): explicitly state plan storage path in prompt by jerop in
|
||||
[#18222](https://github.com/google-gemini/gemini-cli/pull/18222)
|
||||
- A2a admin setting by DavidAPierce in
|
||||
[#17868](https://github.com/google-gemini/gemini-cli/pull/17868)
|
||||
- feat(a2a): Add pluggable auth provider infrastructure by adamfweidman in
|
||||
[#17934](https://github.com/google-gemini/gemini-cli/pull/17934)
|
||||
- Fix handling of empty settings by chrstnb in
|
||||
[#18131](https://github.com/google-gemini/gemini-cli/pull/18131)
|
||||
- Reload skills when extensions change by chrstnb in
|
||||
[#18225](https://github.com/google-gemini/gemini-cli/pull/18225)
|
||||
- feat: Add markdown rendering to ask_user tool by jackwotherspoon in
|
||||
[#18211](https://github.com/google-gemini/gemini-cli/pull/18211)
|
||||
- Add telemetry to rewind by Adib234 in
|
||||
[#18122](https://github.com/google-gemini/gemini-cli/pull/18122)
|
||||
- feat(admin): add support for MCP configuration via admin controls (pt1) by
|
||||
skeshive in [#18223](https://github.com/google-gemini/gemini-cli/pull/18223)
|
||||
- feat(core): require user consent before MCP server OAuth by ehedlund in
|
||||
[#18132](https://github.com/google-gemini/gemini-cli/pull/18132)
|
||||
- fix(sandbox): propagate GOOGLE_GEMINI_BASE_URL&GOOGLE_VERTEX_BASE_URL env vars
|
||||
by skeshive in
|
||||
[#18231](https://github.com/google-gemini/gemini-cli/pull/18231)
|
||||
- feat(ui): move user identity display to header by sehoon38 in
|
||||
[#18216](https://github.com/google-gemini/gemini-cli/pull/18216)
|
||||
- fix: enforce folder trust for workspace settings, skills, and context by
|
||||
galz10 in [#17596](https://github.com/google-gemini/gemini-cli/pull/17596)
|
||||
|
||||
**Full changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.26.0-preview.5...v0.27.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.27.0-preview.8...v0.28.0-preview.0
|
||||
|
||||
@@ -99,3 +99,18 @@ See [Extensions Documentation](../extensions/index.md) for more details.
|
||||
| `gemini mcp list` | List all configured MCP servers | `gemini mcp list` |
|
||||
|
||||
See [MCP Server Integration](../tools/mcp-server.md) for more details.
|
||||
|
||||
## Skills management
|
||||
|
||||
| Command | Description | Example |
|
||||
| -------------------------------- | ------------------------------------- | ------------------------------------------------- |
|
||||
| `gemini skills list` | List all discovered agent skills | `gemini skills list` |
|
||||
| `gemini skills install <source>` | Install skill from Git, path, or file | `gemini skills install https://github.com/u/repo` |
|
||||
| `gemini skills link <path>` | Link local agent skills via symlink | `gemini skills link /path/to/my-skills` |
|
||||
| `gemini skills uninstall <name>` | Uninstall an agent skill | `gemini skills uninstall my-skill` |
|
||||
| `gemini skills enable <name>` | Enable an agent skill | `gemini skills enable my-skill` |
|
||||
| `gemini skills disable <name>` | Disable an agent skill | `gemini skills disable my-skill` |
|
||||
| `gemini skills enable --all` | Enable all skills | `gemini skills enable --all` |
|
||||
| `gemini skills disable --all` | Disable all skills | `gemini skills disable --all` |
|
||||
|
||||
See [Agent Skills Documentation](./skills.md) for more details.
|
||||
|
||||
@@ -113,10 +113,14 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Description:** Lists all active extensions in the current Gemini CLI
|
||||
session. See [Gemini CLI Extensions](../extensions/index.md).
|
||||
|
||||
- **`/help`** (or **`/?`**)
|
||||
- **`/help`**
|
||||
- **Description:** Display help information about Gemini CLI, including
|
||||
available commands and their usage.
|
||||
|
||||
- **`/shortcuts`**
|
||||
- **Description:** Toggle the shortcuts panel above the input.
|
||||
- **Shortcut:** Press `?` when the prompt is empty.
|
||||
|
||||
- **`/hooks`**
|
||||
- **Description:** Manage hooks, which allow you to intercept and customize
|
||||
Gemini CLI behavior at specific lifecycle events.
|
||||
@@ -343,11 +347,11 @@ please see the dedicated [Custom Commands documentation](./custom-commands.md).
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
- **Keyboard shortcut:** Press **Cmd+z** or **Alt+z** to undo the last action
|
||||
- **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action
|
||||
in the input prompt.
|
||||
|
||||
- **Redo:**
|
||||
- **Keyboard shortcut:** Press **Shift+Cmd+Z** or **Shift+Alt+Z** to redo the
|
||||
- **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the
|
||||
last undone action in the input prompt.
|
||||
|
||||
## At commands (`@`)
|
||||
|
||||
@@ -106,16 +106,17 @@ available combinations.
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
||||
| Ctrl+B | `Ctrl + B` |
|
||||
| Ctrl+L | `Ctrl + L` |
|
||||
| Ctrl+K | `Ctrl + K` |
|
||||
| Enter | `Enter` |
|
||||
| Esc | `Esc` |
|
||||
| Shift+Tab | `Shift + Tab` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
|
||||
| Focus the Gemini input from the shell input. | `Tab` |
|
||||
| Toggle current background shell visibility. | `Ctrl + B` |
|
||||
| Toggle background shell list. | `Ctrl + L` |
|
||||
| Kill the active background shell. | `Ctrl + K` |
|
||||
| Confirm selection in background shell list. | `Enter` |
|
||||
| Dismiss background shell list. | `Esc` |
|
||||
| Move focus from background shell to Gemini. | `Shift + Tab` |
|
||||
| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
|
||||
| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` |
|
||||
| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` |
|
||||
| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
|
||||
| Move focus from the shell back to Gemini. | `Shift + Tab` |
|
||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||
| Restart the application. | `R` |
|
||||
| Suspend the application (not yet implemented). | `Ctrl + Z` |
|
||||
@@ -127,6 +128,9 @@ available combinations.
|
||||
- `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your
|
||||
terminal isn't configured to send Meta with Option.
|
||||
- `!` on an empty prompt: Enter or exit shell mode.
|
||||
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press
|
||||
`Esc`, `Backspace`, or any printable key to close it. Press `?` again to close
|
||||
the panel and insert a `?` into the prompt.
|
||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||
mode.
|
||||
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||
|
||||
+9
-12
@@ -22,14 +22,13 @@ they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------- | ---------------------------------- | ------------------------------------------------------------- | ------- |
|
||||
| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| 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` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------ | ---------------------------------- | ------------------------------------------------------------- | ------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| 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` |
|
||||
|
||||
### Output
|
||||
|
||||
@@ -102,9 +101,7 @@ they appear in the UI.
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` |
|
||||
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
|
||||
### Security
|
||||
@@ -115,7 +112,7 @@ they appear in the UI.
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
+16
-1
@@ -52,6 +52,7 @@ locations override lower ones: **Workspace > User > Extension**.
|
||||
Use the `/skills` slash command to view and manage available expertise:
|
||||
|
||||
- `/skills list` (default): Shows all discovered skills and their status.
|
||||
- `/skills link <path>`: Links agent skills from a local directory via symlink.
|
||||
- `/skills disable <name>`: Prevents a specific skill from being used.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
|
||||
@@ -67,6 +68,13 @@ The `gemini skills` command provides management utilities:
|
||||
# List all discovered skills
|
||||
gemini skills list
|
||||
|
||||
# Link agent skills from a local directory via symlink
|
||||
# Discovers skills (SKILL.md or */SKILL.md) and creates symlinks in ~/.gemini/skills (user)
|
||||
gemini skills link /path/to/my-skills-repo
|
||||
|
||||
# Link to the workspace scope (.gemini/skills)
|
||||
gemini skills link /path/to/my-skills-repo --scope workspace
|
||||
|
||||
# Install a skill from a Git repository, local directory, or zipped skill file (.skill)
|
||||
# Uses the user scope by default (~/.gemini/skills)
|
||||
gemini skills install https://github.com/user/repo.git
|
||||
@@ -89,7 +97,7 @@ gemini skills enable my-expertise
|
||||
gemini skills disable my-expertise --scope workspace
|
||||
```
|
||||
|
||||
## How it Works (Security & Privacy)
|
||||
## How it Works
|
||||
|
||||
1. **Discovery**: At the start of a session, Gemini CLI scans the discovery
|
||||
tiers and injects the name and description of all enabled skills into the
|
||||
@@ -106,6 +114,13 @@ gemini skills disable my-expertise --scope workspace
|
||||
5. **Execution**: The model proceeds with the specialized expertise active. It
|
||||
is instructed to prioritize the skill's procedural guidance within reason.
|
||||
|
||||
### Skill activation
|
||||
|
||||
Once a skill is activated (typically by Gemini identifying a task that matches
|
||||
the skill's description and your approval), its specialized instructions and
|
||||
resources are loaded into the agent's context. A skill remains active and its
|
||||
guidance is prioritized for the duration of the session.
|
||||
|
||||
## Creating your own skills
|
||||
|
||||
To create your own skills, see the [Create Agent Skills](./creating-skills.md)
|
||||
|
||||
@@ -320,6 +320,8 @@ Captures startup configuration and user prompt submissions.
|
||||
|
||||
Tracks changes and duration of approval modes.
|
||||
|
||||
##### Lifecycle
|
||||
|
||||
- `approval_mode_switch`: Approval mode was changed.
|
||||
- **Attributes**:
|
||||
- `from_mode` (string)
|
||||
@@ -330,6 +332,15 @@ Tracks changes and duration of approval modes.
|
||||
- `mode` (string)
|
||||
- `duration_ms` (int)
|
||||
|
||||
##### Execution
|
||||
|
||||
These events track the execution of an approval mode, such as Plan Mode.
|
||||
|
||||
- `plan_execution`: A plan was executed and the session switched from plan mode
|
||||
to active execution.
|
||||
- **Attributes**:
|
||||
- `approval_mode` (string)
|
||||
|
||||
#### Tools
|
||||
|
||||
Captures tool executions, output truncation, and Edit behavior.
|
||||
@@ -710,6 +721,17 @@ Agent lifecycle metrics: runs, durations, and turns.
|
||||
- **Attributes**:
|
||||
- `agent_name` (string)
|
||||
|
||||
##### Approval Mode
|
||||
|
||||
###### Execution
|
||||
|
||||
These metrics track the adoption and usage of specific approval workflows, such
|
||||
as Plan Mode.
|
||||
|
||||
- `gemini_cli.plan.execution.count` (Counter, Int): Counts plan executions.
|
||||
- **Attributes**:
|
||||
- `approval_mode` (string)
|
||||
|
||||
##### UI
|
||||
|
||||
UI stability signals such as flicker count.
|
||||
|
||||
@@ -146,8 +146,8 @@ it yourself; just report it.
|
||||
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
|
||||
|
||||
### Optimizing your sub-agent
|
||||
|
||||
|
||||
@@ -98,10 +98,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `general`
|
||||
|
||||
- **`general.previewFeatures`** (boolean):
|
||||
- **Description:** Enable preview features (e.g., preview models).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.preferredEditor`** (string):
|
||||
- **Description:** The preferred editor to open files in.
|
||||
- **Default:** `undefined`
|
||||
@@ -720,20 +716,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
implementation. Provides faster search performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.enableToolOutputTruncation`** (boolean):
|
||||
- **Description:** Enable truncation of large tool outputs.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputThreshold`** (number):
|
||||
- **Description:** Truncate tool output if it is larger than this many
|
||||
characters. Set to -1 to disable.
|
||||
- **Default:** `4000000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputLines`** (number):
|
||||
- **Description:** The number of lines to keep when truncating tool output.
|
||||
- **Default:** `1000`
|
||||
- **Description:** Maximum characters to show when truncating large tool
|
||||
outputs. Set to 0 or negative to disable truncation.
|
||||
- **Default:** `40000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.disableLLMCorrection`** (boolean):
|
||||
@@ -792,7 +778,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`security.folderTrust.enabled`** (boolean):
|
||||
- **Description:** Setting to track whether Folder trust is enabled.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.environmentVariableRedaction.allowed`** (array):
|
||||
@@ -866,11 +852,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.extensionConfig`** (boolean):
|
||||
- **Description:** Enable requesting and fetching of extension settings.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableEventDrivenScheduler`** (boolean):
|
||||
- **Description:** Enables event-driven scheduler within the CLI session.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -995,6 +976,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** If false, disallows MCP servers from being used.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`admin.mcp.config`** (object):
|
||||
- **Description:** Admin-configured MCP servers.
|
||||
- **Default:** `{}`
|
||||
|
||||
- **`admin.skills.enabled`** (boolean):
|
||||
- **Description:** If false, disallows agent skills from being used.
|
||||
- **Default:** `true`
|
||||
|
||||
+2
-4
@@ -100,10 +100,8 @@ Connect Gemini CLI to external services and other development tools.
|
||||
the Model Context Protocol.
|
||||
- **[IDE integration](./ide-integration/index.md):** Use Gemini CLI alongside VS
|
||||
Code.
|
||||
- **[Hooks](./hooks/index.md):** (Preview) Write scripts that run on specific
|
||||
CLI events.
|
||||
- **[Agent skills](./cli/skills.md):** (Preview) Add specialized expertise and
|
||||
workflows.
|
||||
- **[Hooks](./hooks/index.md):** Write scripts that run on specific CLI events.
|
||||
- **[Agent skills](./cli/skills.md):** Add specialized expertise and workflows.
|
||||
- **[Sub-agents](./core/subagents.md):** (Preview) Delegate tasks to specialized
|
||||
agents.
|
||||
|
||||
|
||||
+8
-8
@@ -123,14 +123,6 @@
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/faq" },
|
||||
{ "label": "Quota and pricing", "slug": "docs/quota-and-pricing" },
|
||||
{
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
|
||||
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
|
||||
]
|
||||
},
|
||||
{ "label": "Terms and privacy", "slug": "docs/tos-privacy" },
|
||||
{ "label": "Troubleshooting", "slug": "docs/troubleshooting" },
|
||||
{ "label": "Uninstall", "slug": "docs/cli/uninstall" }
|
||||
@@ -148,5 +140,13 @@
|
||||
{ "label": "Local development", "slug": "docs/local-development" },
|
||||
{ "label": "NPM package structure", "slug": "docs/npm" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Stable release", "slug": "docs/changelogs/latest" },
|
||||
{ "label": "Preview release", "slug": "docs/changelogs/preview" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('Automated tool use', () => {
|
||||
/**
|
||||
* Tests that the agent always utilizes --fix when calling eslint.
|
||||
* We provide a 'lint' script in the package.json, which helps elicit
|
||||
* a repro by guiding the agent into using the existing deficient script.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use automated tools (eslint --fix) to fix code style issues',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'typescript-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {
|
||||
lint: 'eslint .',
|
||||
},
|
||||
devDependencies: {
|
||||
eslint: '^9.0.0',
|
||||
globals: '^15.0.0',
|
||||
typescript: '^5.0.0',
|
||||
'typescript-eslint': '^8.0.0',
|
||||
'@eslint/js': '^9.0.0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'eslint.config.js': `
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts}"],
|
||||
languageOptions: {
|
||||
globals: globals.node
|
||||
}
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"prefer-const": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
}
|
||||
}
|
||||
];
|
||||
`,
|
||||
'src/app.ts': `
|
||||
export function main() {
|
||||
let count = 10;
|
||||
console.log(count);
|
||||
}
|
||||
`,
|
||||
},
|
||||
prompt:
|
||||
'Fix the linter errors in this project. Make sure to avoid interactive commands.',
|
||||
assert: async (rig) => {
|
||||
// Check if run_shell_command was used with --fix
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCommands = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasFixCommand = shellCommands.some((call) => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const cmd = (args as any)['command'];
|
||||
return (
|
||||
cmd &&
|
||||
(cmd.includes('eslint') || cmd.includes('npm run lint')) &&
|
||||
cmd.includes('--fix')
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
hasFixCommand,
|
||||
'Expected agent to use eslint --fix via run_shell_command',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests that the agent uses prettier --write to fix formatting issues in files
|
||||
* instead of trying to edit the files itself.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use automated tools (prettier --write) to fix formatting issues',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'typescript-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: {},
|
||||
devDependencies: {
|
||||
prettier: '^3.0.0',
|
||||
typescript: '^5.0.0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'.prettierrc': JSON.stringify(
|
||||
{
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'src/app.ts': `
|
||||
export function main() {
|
||||
const data={ name:'test',
|
||||
val:123
|
||||
}
|
||||
console.log(data)
|
||||
}
|
||||
`,
|
||||
},
|
||||
prompt:
|
||||
'Fix the formatting errors in this project. Make sure to avoid interactive commands.',
|
||||
assert: async (rig) => {
|
||||
// Check if run_shell_command was used with --write
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCommands = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasFixCommand = shellCommands.some((call) => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const cmd = (args as any)['command'];
|
||||
return (
|
||||
cmd &&
|
||||
cmd.includes('prettier') &&
|
||||
(cmd.includes('--write') || cmd.includes('-w'))
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
hasFixCommand,
|
||||
'Expected agent to use prettier --write via run_shell_command',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('interactive_commands', () => {
|
||||
/**
|
||||
* Validates that the agent does not use interactive commands unprompted.
|
||||
* Interactive commands block the progress of the agent, requiring user
|
||||
* intervention.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not use interactive commands',
|
||||
prompt: 'Execute tests.',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'example',
|
||||
type: 'module',
|
||||
devDependencies: {
|
||||
vitest: 'latest',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'example.test.js': `
|
||||
import { test, expect } from 'vitest';
|
||||
test('it works', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
`,
|
||||
},
|
||||
assert: async (rig, result) => {
|
||||
const logs = rig.readToolLogs();
|
||||
const vitestCall = logs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'run_shell_command' &&
|
||||
l.toolRequest.args.toLowerCase().includes('vitest'),
|
||||
);
|
||||
|
||||
expect(vitestCall, 'Agent should have called vitest').toBeDefined();
|
||||
expect(
|
||||
vitestCall?.toolRequest.args,
|
||||
'Agent should have passed run arg',
|
||||
).toMatch(/\b(run|--run)\b/);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import {
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('plan_mode', () => {
|
||||
const TEST_PREFIX = 'Plan Mode: ';
|
||||
const settings = {
|
||||
experimental: { plan: true },
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should refuse file modification when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
files: {
|
||||
'README.md': '# Original Content',
|
||||
},
|
||||
prompt: 'Please overwrite README.md with the text "Hello World"',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeTargets = toolLogs
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
)
|
||||
.map((log) => {
|
||||
try {
|
||||
return JSON.parse(log.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
writeTargets,
|
||||
'Should not attempt to modify README.md in plan mode',
|
||||
).not.toContain('README.md');
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i],
|
||||
testName: `${TEST_PREFIX}should refuse file modification`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should enter plan mode when asked to create a plan',
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'I need to build a complex new feature for user authentication. Please create a detailed implementation plan.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(wasToolCalled, 'Expected enter_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should exit plan mode when plan is complete and implementation is requested',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
files: {
|
||||
'plans/my-plan.md':
|
||||
'# My Implementation Plan\n\n1. Step one\n2. Step two',
|
||||
},
|
||||
prompt:
|
||||
'The plan in plans/my-plan.md is solid. Please proceed with the implementation.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('exit_plan_mode');
|
||||
expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should allow file modification in plans directory when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt: 'Create a plan for a new login feature.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'write_file',
|
||||
);
|
||||
|
||||
expect(
|
||||
writeCall,
|
||||
'Should attempt to modify a file in the plans directory when in plan mode',
|
||||
).toBeDefined();
|
||||
|
||||
if (writeCall) {
|
||||
const args = JSON.parse(writeCall.toolRequest.args);
|
||||
expect(args.file_path).toContain('.gemini/tmp');
|
||||
expect(args.file_path).toContain('/plans/');
|
||||
expect(args.file_path).toMatch(/\.md$/);
|
||||
}
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
+217
-8
@@ -6,11 +6,16 @@
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import { validateModelOutput } from '../integration-tests/test-helper.js';
|
||||
import {
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from '../integration-tests/test-helper.js';
|
||||
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should be able to save to memory',
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
@@ -18,13 +23,217 @@ describe('save_memory', () => {
|
||||
|
||||
what is my favorite color? tell me that and surround it with $ symbol`,
|
||||
assert: async (rig, result) => {
|
||||
const foundToolCall = await rig.waitForToolCall('save_memory');
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a save_memory tool call',
|
||||
).toBeTruthy();
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
validateModelOutput(result, 'blue', 'Save memory test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'blue',
|
||||
testName: `${TEST_PREFIX}${rememberingFavoriteColor}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I don't want you to ever run npm commands.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/not run npm commands|remember|ok/i],
|
||||
testName: `${TEST_PREFIX}${rememberingCommandRestrictions}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I want you to always lint after building.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/always|ok|remember|will do/i],
|
||||
testName: `${TEST_PREFIX}${rememberingWorkflow}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ignoringTemporaryInformation =
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I'm going to get a coffee.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const wasToolCalled = rig
|
||||
.readToolLogs()
|
||||
.some((log) => log.toolRequest.name === 'save_memory');
|
||||
expect(
|
||||
wasToolCalled,
|
||||
'save_memory should not be called for temporary information',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
testName: `${TEST_PREFIX}${ignoringTemporaryInformation}`,
|
||||
forbiddenContent: [/remember|will do/i],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `My dog's name is Buddy. What is my dog's name?`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/Buddy/i],
|
||||
testName: `${TEST_PREFIX}${rememberingPetName}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/npm run dev|start server|ok|remember|will do/i],
|
||||
testName: `${TEST_PREFIX}${rememberingCommandAlias}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingDbSchemaLocation =
|
||||
"Agent remembers project's database schema location";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingDbSchemaLocation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `The database schema for this project is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/database schema|ok|remember|will do/i],
|
||||
testName: `${TEST_PREFIX}${rememberingDbSchemaLocation}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingCodingStyle =
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/tabs instead of spaces|ok|remember|will do/i],
|
||||
testName: `${TEST_PREFIX}${rememberingCodingStyle}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingTestCommand =
|
||||
'Agent remembers specific project test command';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingTestCommand,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `The command to run all backend tests is \`npm run test:backend\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [
|
||||
/command to run all backend tests|ok|remember|will do/i,
|
||||
],
|
||||
testName: `${TEST_PREFIX}${rememberingTestCommand}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rememberingMainEntryPoint =
|
||||
"Agent remembers project's main entry point";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingMainEntryPoint,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
prompt: `The main entry point for this project is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [
|
||||
/main entry point for this project|ok|remember|will do/i,
|
||||
],
|
||||
testName: `${TEST_PREFIX}${rememberingMainEntryPoint}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,14 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
try {
|
||||
rig.setup(evalCase.name, evalCase.params);
|
||||
|
||||
// Symlink node modules to reduce the amount of time needed to
|
||||
// bootstrap test projects.
|
||||
const rootNodeModules = path.join(process.cwd(), 'node_modules');
|
||||
const testNodeModules = path.join(rig.testDir || '', 'node_modules');
|
||||
if (fs.existsSync(rootNodeModules)) {
|
||||
fs.symlinkSync(rootNodeModules, testNodeModules, 'dir');
|
||||
}
|
||||
|
||||
if (evalCase.files) {
|
||||
const acknowledgedAgents: Record<string, Record<string, string>> = {};
|
||||
const projectRoot = fs.realpathSync(rig.testDir!);
|
||||
@@ -117,7 +125,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
approvalMode: evalCase.approvalMode ?? 'yolo',
|
||||
timeout: evalCase.timeout,
|
||||
env: {
|
||||
GEMINI_CLI_ACTIVITY_LOG_FILE: activityLogFile,
|
||||
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('file-system', () => {
|
||||
let rig: TestRig;
|
||||
@@ -43,8 +48,11 @@ describe('file-system', () => {
|
||||
'Expected to find a read_file tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'hello world', 'File read test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'hello world',
|
||||
testName: 'File read test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to write a file', async () => {
|
||||
@@ -74,8 +82,8 @@ describe('file-system', () => {
|
||||
'Expected to find a write_file, edit, or replace tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
// Validate model output - will throw if no output
|
||||
validateModelOutput(result, null, 'File write test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, { testName: 'File write test' });
|
||||
|
||||
const fileContent = rig.readFile('test.txt');
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import { WEB_SEARCH_TOOL_NAME } from '../packages/core/src/tools/tool-names.js';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('web search tool', () => {
|
||||
let rig: TestRig;
|
||||
@@ -68,12 +73,11 @@ describe('web search tool', () => {
|
||||
`Expected to find a call to ${WEB_SEARCH_TOOL_NAME}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
const hasExpectedContent = validateModelOutput(
|
||||
result,
|
||||
['weather', 'london'],
|
||||
'Google web search test',
|
||||
);
|
||||
assertModelHasOutput(result);
|
||||
const hasExpectedContent = checkModelOutputContent(result, {
|
||||
expectedContent: ['weather', 'london'],
|
||||
testName: 'Google web search test',
|
||||
});
|
||||
|
||||
// If content was missing, log the search queries used
|
||||
if (!hasExpectedContent) {
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
TestRig,
|
||||
poll,
|
||||
printDebugInfo,
|
||||
validateModelOutput,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -68,7 +69,10 @@ describe('list_directory', () => {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, ['file1.txt', 'subdir'], 'List directory test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: ['file1.txt', 'subdir'],
|
||||
testName: 'List directory test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('read_many_files', () => {
|
||||
let rig: TestRig;
|
||||
@@ -50,7 +55,7 @@ describe('read_many_files', () => {
|
||||
'Expected to find either read_many_files or multiple read_file tool calls',
|
||||
).toBeTruthy();
|
||||
|
||||
// Validate model output - will throw if no output
|
||||
validateModelOutput(result, null, 'Read many files test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, { testName: 'Read many files test' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Session started."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('resume-repro', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should be able to resume a session without "Storage must be initialized before use"', async () => {
|
||||
const responsesPath = path.join(__dirname, 'resume_repro.responses');
|
||||
await rig.setup('should be able to resume a session', {
|
||||
fakeResponsesPath: responsesPath,
|
||||
});
|
||||
|
||||
// 1. First run to create a session
|
||||
await rig.run({
|
||||
args: 'hello',
|
||||
});
|
||||
|
||||
// 2. Second run with --resume latest
|
||||
// This should NOT fail with "Storage must be initialized before use"
|
||||
const result = await rig.run({
|
||||
args: ['--resume', 'latest', 'continue'],
|
||||
});
|
||||
|
||||
expect(result).toContain('Session started');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
import { getShellConfiguration } from '../packages/core/src/utils/shell-utils.js';
|
||||
|
||||
const { shell } = getShellConfiguration();
|
||||
@@ -115,13 +120,11 @@ describe('run_shell_command', () => {
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
// Model often reports exit code instead of showing output
|
||||
validateModelOutput(
|
||||
result,
|
||||
['hello-world', 'exit code 0'],
|
||||
'Shell command test',
|
||||
);
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: ['hello-world', 'exit code 0'],
|
||||
testName: 'Shell command test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to run a shell command via stdin', async () => {
|
||||
@@ -149,8 +152,11 @@ describe('run_shell_command', () => {
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'test-stdin', 'Shell command stdin test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'test-stdin',
|
||||
testName: 'Shell command stdin test',
|
||||
});
|
||||
});
|
||||
|
||||
it.skip('should run allowed sub-command in non-interactive mode', async () => {
|
||||
@@ -494,12 +500,11 @@ describe('run_shell_command', () => {
|
||||
)[0];
|
||||
expect(toolCall.toolRequest.success).toBe(true);
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(
|
||||
result,
|
||||
'test-allow-all',
|
||||
'Shell command stdin allow all',
|
||||
);
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'test-allow-all',
|
||||
testName: 'Shell command stdin allow all',
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate environment variables to the child process', async () => {
|
||||
@@ -528,7 +533,11 @@ describe('run_shell_command', () => {
|
||||
foundToolCall,
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
validateModelOutput(result, varValue, 'Env var propagation test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: varValue,
|
||||
testName: 'Env var propagation test',
|
||||
});
|
||||
expect(result).toContain(varValue);
|
||||
} finally {
|
||||
delete process.env[varName];
|
||||
@@ -558,7 +567,11 @@ describe('run_shell_command', () => {
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
validateModelOutput(result, fileName, 'Platform-specific listing test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: fileName,
|
||||
testName: 'Platform-specific listing test',
|
||||
});
|
||||
expect(result).toContain(fileName);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, poll, validateModelOutput } from './test-helper.js';
|
||||
import {
|
||||
TestRig,
|
||||
poll,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
@@ -226,8 +231,11 @@ describe.skip('simple-mcp-server', () => {
|
||||
|
||||
expect(foundToolCall, 'Expected to find an add tool call').toBeTruthy();
|
||||
|
||||
// Validate model output - will throw if no output, fail if missing expected content
|
||||
validateModelOutput(output, '15', 'MCP server test');
|
||||
assertModelHasOutput(output);
|
||||
checkModelOutputContent(output, {
|
||||
expectedContent: '15',
|
||||
testName: 'MCP server test',
|
||||
});
|
||||
expect(
|
||||
output.includes('15'),
|
||||
'Expected output to contain the sum (15)',
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
import {
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe.skip('stdin context', () => {
|
||||
let rig: TestRig;
|
||||
@@ -67,7 +72,11 @@ describe.skip('stdin context', () => {
|
||||
}
|
||||
|
||||
// Validate model output
|
||||
validateModelOutput(result, randomString, 'STDIN context test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: randomString,
|
||||
testName: 'STDIN context test',
|
||||
});
|
||||
|
||||
expect(
|
||||
result.toLowerCase().includes(randomString),
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
TestRig,
|
||||
createToolCallErrorMessage,
|
||||
printDebugInfo,
|
||||
validateModelOutput,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('write_file', () => {
|
||||
@@ -46,8 +47,11 @@ describe('write_file', () => {
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'dad.txt', 'Write file test');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: 'dad.txt',
|
||||
testName: 'Write file test',
|
||||
});
|
||||
|
||||
const newFilePath = 'dad.txt';
|
||||
|
||||
|
||||
Generated
+64
-7
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -26,6 +27,7 @@
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
@@ -4102,6 +4104,16 @@
|
||||
"kleur": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/proper-lockfile": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
|
||||
"integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/retry": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@@ -4196,6 +4208,13 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/retry": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
|
||||
"integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/sarif": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz",
|
||||
@@ -4326,6 +4345,16 @@
|
||||
"boxen": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||
@@ -14040,6 +14069,32 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile/node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
@@ -17999,7 +18054,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18055,7 +18110,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -18094,6 +18149,7 @@
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -18112,6 +18168,7 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
@@ -18142,7 +18199,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
@@ -18300,7 +18357,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18317,7 +18374,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"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.28.0-nightly.20260128.adc8e11bb"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -32,7 +32,7 @@
|
||||
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
|
||||
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
"build-and-start": "npm run build && npm run start --",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
|
||||
"build:packages": "npm run build --workspaces",
|
||||
@@ -86,6 +86,7 @@
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
@@ -126,6 +127,7 @@
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -11,6 +11,11 @@ import type { Settings } from './settings.js';
|
||||
import {
|
||||
type ExtensionLoader,
|
||||
FileDiscoveryService,
|
||||
getCodeAssistServer,
|
||||
Config,
|
||||
ExperimentFlags,
|
||||
fetchAdminControlsOnce,
|
||||
type FetchAdminControlsResponse,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -19,11 +24,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Config: vi.fn().mockImplementation((params) => ({
|
||||
initialize: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
...params, // Expose params for assertion
|
||||
})),
|
||||
Config: vi.fn().mockImplementation((params) => {
|
||||
const mockConfig = {
|
||||
...params,
|
||||
initialize: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getExperiments: vi.fn().mockReturnValue({
|
||||
flags: {
|
||||
[actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
|
||||
boolValue: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
};
|
||||
return mockConfig;
|
||||
}),
|
||||
loadServerHierarchicalMemory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
|
||||
@@ -31,6 +48,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
FileDiscoveryService: vi.fn(),
|
||||
getCodeAssistServer: vi.fn(),
|
||||
fetchAdminControlsOnce: vi.fn(),
|
||||
coreEvents: {
|
||||
emitAdminSettingsChanged: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -56,6 +78,121 @@ describe('loadConfig', () => {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
describe('admin settings overrides', () => {
|
||||
it('should not fetch admin controls if experiment is disabled', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when admin controls experiment is enabled', () => {
|
||||
beforeEach(() => {
|
||||
// We need to cast to any here to modify the mock implementation
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Config as any).mockImplementation((params: unknown) => {
|
||||
const mockConfig = {
|
||||
...(params as object),
|
||||
initialize: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getExperiments: vi.fn().mockReturnValue({
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
|
||||
boolValue: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
getRemoteAdminSettings: vi.fn().mockReturnValue({}),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
};
|
||||
return mockConfig;
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch admin controls and apply them', async () => {
|
||||
const mockAdminSettings: FetchAdminControlsResponse = {
|
||||
mcpSetting: {
|
||||
mcpEnabled: false,
|
||||
},
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: {
|
||||
extensionsEnabled: false,
|
||||
},
|
||||
},
|
||||
strictModeDisabled: false,
|
||||
};
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(Config).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
disableYoloMode: !mockAdminSettings.strictModeDisabled,
|
||||
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
|
||||
extensionsEnabled:
|
||||
mockAdminSettings.cliFeatureSetting?.extensionsSetting
|
||||
?.extensionsEnabled,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat unset admin settings as false when admin settings are passed', async () => {
|
||||
const mockAdminSettings: FetchAdminControlsResponse = {
|
||||
mcpSetting: {
|
||||
mcpEnabled: true,
|
||||
},
|
||||
};
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(Config).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
disableYoloMode: !false,
|
||||
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
|
||||
extensionsEnabled: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not pass default unset admin settings when no admin settings are present', async () => {
|
||||
const mockAdminSettings: FetchAdminControlsResponse = {};
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({}));
|
||||
});
|
||||
|
||||
it('should fetch admin controls using the code assist server when available', async () => {
|
||||
const mockAdminSettings: FetchAdminControlsResponse = {
|
||||
mcpSetting: {
|
||||
mcpEnabled: true,
|
||||
},
|
||||
strictModeDisabled: true,
|
||||
};
|
||||
const mockCodeAssistServer = { projectId: 'test-project' };
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockCodeAssistServer as any,
|
||||
);
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
|
||||
mockCodeAssistServer,
|
||||
true,
|
||||
);
|
||||
expect(Config).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
disableYoloMode: !mockAdminSettings.strictModeDisabled,
|
||||
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
|
||||
extensionsEnabled: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
|
||||
|
||||
@@ -18,12 +18,14 @@ import {
|
||||
loadServerHierarchicalMemory,
|
||||
GEMINI_DIR,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
type ExtensionLoader,
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
GitService,
|
||||
fetchAdminControlsOnce,
|
||||
getCodeAssistServer,
|
||||
ExperimentFlags,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
@@ -57,9 +59,7 @@ export async function loadConfig(
|
||||
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: taskId,
|
||||
model: settings.general?.previewFeatures
|
||||
? PREVIEW_GEMINI_MODEL
|
||||
: DEFAULT_GEMINI_MODEL,
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
|
||||
targetDir: workspaceDir, // Or a specific directory the agent operates on
|
||||
@@ -101,7 +101,6 @@ export async function loadConfig(
|
||||
trustedFolder: true,
|
||||
extensionLoader,
|
||||
checkpointing,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
interactive: true,
|
||||
enableInteractiveShell: true,
|
||||
ptyInfo: 'auto',
|
||||
@@ -124,37 +123,50 @@ export async function loadConfig(
|
||||
configParams.userMemory = memoryContent;
|
||||
configParams.geminiMdFileCount = fileCount;
|
||||
configParams.geminiMdFilePaths = filePaths;
|
||||
const config = new Config({
|
||||
|
||||
// Set an initial config to use to get a code assist server.
|
||||
// This is needed to fetch admin controls.
|
||||
const initialConfig = new Config({
|
||||
...configParams,
|
||||
});
|
||||
|
||||
const codeAssistServer = getCodeAssistServer(initialConfig);
|
||||
|
||||
const adminControlsEnabled =
|
||||
initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
|
||||
?.boolValue ?? false;
|
||||
|
||||
// Initialize final config parameters to the previous parameters.
|
||||
// If no admin controls are needed, these will be used as-is for the final
|
||||
// config.
|
||||
const finalConfigParams = { ...configParams };
|
||||
if (adminControlsEnabled) {
|
||||
const adminSettings = await fetchAdminControlsOnce(
|
||||
codeAssistServer,
|
||||
adminControlsEnabled,
|
||||
);
|
||||
|
||||
// Admin settings are able to be undefined if unset, but if any are present,
|
||||
// we should initialize them all.
|
||||
// If any are present, undefined settings should be treated as if they were
|
||||
// set to false.
|
||||
// If NONE are present, disregard admin settings entirely, and pass the
|
||||
// final config as is.
|
||||
if (Object.keys(adminSettings).length !== 0) {
|
||||
finalConfigParams.disableYoloMode = !adminSettings.strictModeDisabled;
|
||||
finalConfigParams.mcpEnabled = adminSettings.mcpSetting?.mcpEnabled;
|
||||
finalConfigParams.extensionsEnabled =
|
||||
adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
const config = new Config(finalConfigParams);
|
||||
|
||||
// Needed to initialize ToolRegistry, and git checkpointing if enabled
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
if (process.env['USE_CCPA']) {
|
||||
logger.info('[Config] Using CCPA Auth:');
|
||||
try {
|
||||
if (adcFilePath) {
|
||||
path.resolve(adcFilePath);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[Config] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
|
||||
);
|
||||
}
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
logger.info(
|
||||
`[Config] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||
);
|
||||
} else if (process.env['GEMINI_API_KEY']) {
|
||||
logger.info('[Config] Using Gemini API Key');
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
} else {
|
||||
const errorMessage =
|
||||
'[Config] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.';
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
await refreshAuthentication(config, adcFilePath, 'Config');
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -222,3 +234,33 @@ function findEnvFile(startDir: string): string | null {
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuthentication(
|
||||
config: Config,
|
||||
adcFilePath: string | undefined,
|
||||
logPrefix: string,
|
||||
): Promise<void> {
|
||||
if (process.env['USE_CCPA']) {
|
||||
logger.info(`[${logPrefix}] Using CCPA Auth:`);
|
||||
try {
|
||||
if (adcFilePath) {
|
||||
path.resolve(adcFilePath);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
|
||||
);
|
||||
}
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
logger.info(
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||
);
|
||||
} else if (process.env['GEMINI_API_KEY']) {
|
||||
logger.info(`[${logPrefix}] Using Gemini API Key`);
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
} else {
|
||||
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,67 +89,6 @@ describe('loadSettings', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should load nested previewFeatures from user settings', () => {
|
||||
const settings = {
|
||||
general: {
|
||||
previewFeatures: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBe(true);
|
||||
});
|
||||
|
||||
it('should load nested previewFeatures from workspace settings', () => {
|
||||
const settings = {
|
||||
general: {
|
||||
previewFeatures: true,
|
||||
},
|
||||
};
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize workspace settings over user settings', () => {
|
||||
const userSettings = {
|
||||
general: {
|
||||
previewFeatures: false,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = {
|
||||
general: {
|
||||
previewFeatures: true,
|
||||
},
|
||||
};
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing previewFeatures', () => {
|
||||
const settings = {
|
||||
general: {},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load other top-level settings correctly', () => {
|
||||
const settings = {
|
||||
showMemoryUsage: true,
|
||||
|
||||
@@ -31,9 +31,6 @@ export interface Settings {
|
||||
showMemoryUsage?: boolean;
|
||||
checkpointing?: CheckpointingSettings;
|
||||
folderTrust?: boolean;
|
||||
general?: {
|
||||
previewFeatures?: boolean;
|
||||
};
|
||||
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
import {
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
GeminiClient,
|
||||
HookSystem,
|
||||
@@ -47,7 +46,6 @@ export function createMockConfig(
|
||||
} as Storage,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.28.0-nightly.20260128.adc8e11bb",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"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.28.0-nightly.20260128.adc8e11bb"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -65,6 +65,7 @@
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -80,6 +81,7 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
|
||||
@@ -17,32 +17,26 @@ import yargs from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
updateSetting,
|
||||
promptForSetting,
|
||||
getScopedEnvContents,
|
||||
type ExtensionSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import prompts from 'prompts';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const {
|
||||
mockExtensionManager,
|
||||
mockGetExtensionAndManager,
|
||||
mockGetExtensionManager,
|
||||
mockLoadSettings,
|
||||
} = vi.hoisted(() => {
|
||||
const extensionManager = {
|
||||
loadExtensionConfig: vi.fn(),
|
||||
getExtensions: vi.fn(),
|
||||
loadExtensions: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
};
|
||||
return {
|
||||
mockExtensionManager: extensionManager,
|
||||
mockGetExtensionAndManager: vi.fn(),
|
||||
mockGetExtensionManager: vi.fn(),
|
||||
mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }),
|
||||
};
|
||||
});
|
||||
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
|
||||
vi.hoisted(() => {
|
||||
const extensionManager = {
|
||||
loadExtensionConfig: vi.fn(),
|
||||
getExtensions: vi.fn(),
|
||||
loadExtensions: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
};
|
||||
return {
|
||||
mockExtensionManager: extensionManager,
|
||||
mockGetExtensionManager: vi.fn(),
|
||||
mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js', () => ({
|
||||
ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager),
|
||||
@@ -62,10 +56,13 @@ vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
getExtensionAndManager: mockGetExtensionAndManager,
|
||||
getExtensionManager: mockGetExtensionManager,
|
||||
}));
|
||||
vi.mock('./utils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./utils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getExtensionManager: mockGetExtensionManager,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('prompts');
|
||||
|
||||
@@ -91,10 +88,6 @@ describe('extensions configure command', () => {
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
// Default behaviors
|
||||
mockLoadSettings.mockReturnValue({ merged: {} });
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: null,
|
||||
extensionManager: null,
|
||||
});
|
||||
mockGetExtensionManager.mockResolvedValue(mockExtensionManager);
|
||||
(ExtensionManager as unknown as Mock).mockImplementation(
|
||||
() => mockExtensionManager,
|
||||
@@ -117,11 +110,6 @@ describe('extensions configure command', () => {
|
||||
path = '/test/path',
|
||||
) => {
|
||||
const extension = { name, path, id };
|
||||
mockGetExtensionAndManager.mockImplementation(async (n) => {
|
||||
if (n === name)
|
||||
return { extension, extensionManager: mockExtensionManager };
|
||||
return { extension: null, extensionManager: null };
|
||||
});
|
||||
|
||||
mockExtensionManager.getExtensions.mockReturnValue([extension]);
|
||||
mockExtensionManager.loadExtensionConfig.mockResolvedValue({
|
||||
@@ -144,17 +132,14 @@ describe('extensions configure command', () => {
|
||||
expect.objectContaining({ name: 'test-ext' }),
|
||||
'test-id',
|
||||
'TEST_VAR',
|
||||
promptForSetting,
|
||||
expect.any(Function),
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing extension', async () => {
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: null,
|
||||
extensionManager: null,
|
||||
});
|
||||
mockExtensionManager.getExtensions.mockReturnValue([]);
|
||||
|
||||
await runCommand('config missing-ext TEST_VAR');
|
||||
|
||||
@@ -190,7 +175,7 @@ describe('extensions configure command', () => {
|
||||
expect.objectContaining({ name: 'test-ext' }),
|
||||
'test-id',
|
||||
'VAR_1',
|
||||
promptForSetting,
|
||||
expect.any(Function),
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
@@ -205,7 +190,7 @@ describe('extensions configure command', () => {
|
||||
return {};
|
||||
},
|
||||
);
|
||||
(prompts as unknown as Mock).mockResolvedValue({ overwrite: true });
|
||||
(prompts as unknown as Mock).mockResolvedValue({ confirm: true });
|
||||
(updateSetting as Mock).mockResolvedValue(undefined);
|
||||
|
||||
await runCommand('config test-ext');
|
||||
@@ -241,7 +226,7 @@ describe('extensions configure command', () => {
|
||||
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
|
||||
setupExtension('test-ext', settings);
|
||||
(getScopedEnvContents as Mock).mockResolvedValue({ VAR_1: 'existing' });
|
||||
(prompts as unknown as Mock).mockResolvedValue({ overwrite: false });
|
||||
(prompts as unknown as Mock).mockResolvedValue({ confirm: false });
|
||||
|
||||
await runCommand('config test-ext');
|
||||
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import type { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||
import {
|
||||
updateSetting,
|
||||
promptForSetting,
|
||||
ExtensionSettingScope,
|
||||
getScopedEnvContents,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import { getExtensionAndManager, getExtensionManager } from './utils.js';
|
||||
configureAllExtensions,
|
||||
configureExtension,
|
||||
configureSpecificSetting,
|
||||
getExtensionManager,
|
||||
} from './utils.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import prompts from 'prompts';
|
||||
import type { ExtensionConfig } from '../../config/extension.js';
|
||||
|
||||
interface ConfigureArgs {
|
||||
name?: string;
|
||||
setting?: string;
|
||||
@@ -64,9 +63,12 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
}
|
||||
}
|
||||
|
||||
const extensionManager = await getExtensionManager();
|
||||
|
||||
// Case 1: Configure specific setting for an extension
|
||||
if (name && setting) {
|
||||
await configureSpecificSetting(
|
||||
extensionManager,
|
||||
name,
|
||||
setting,
|
||||
scope as ExtensionSettingScope,
|
||||
@@ -74,152 +76,20 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
}
|
||||
// Case 2: Configure all settings for an extension
|
||||
else if (name) {
|
||||
await configureExtension(name, scope as ExtensionSettingScope);
|
||||
await configureExtension(
|
||||
extensionManager,
|
||||
name,
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
// Case 3: Configure all extensions
|
||||
else {
|
||||
await configureAllExtensions(scope as ExtensionSettingScope);
|
||||
await configureAllExtensions(
|
||||
extensionManager,
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
async function configureSpecificSetting(
|
||||
extensionName: string,
|
||||
settingKey: string,
|
||||
scope: ExtensionSettingScope,
|
||||
) {
|
||||
const { extension, extensionManager } =
|
||||
await getExtensionAndManager(extensionName);
|
||||
if (!extension || !extensionManager) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (!extensionConfig) {
|
||||
debugLogger.error(
|
||||
`Could not find configuration for extension "${extensionName}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
settingKey,
|
||||
promptForSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
async function configureExtension(
|
||||
extensionName: string,
|
||||
scope: ExtensionSettingScope,
|
||||
) {
|
||||
const { extension, extensionManager } =
|
||||
await getExtensionAndManager(extensionName);
|
||||
if (!extension || !extensionManager) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
!extensionConfig ||
|
||||
!extensionConfig.settings ||
|
||||
extensionConfig.settings.length === 0
|
||||
) {
|
||||
debugLogger.log(
|
||||
`Extension "${extensionName}" has no settings to configure.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(`Configuring settings for "${extensionName}"...`);
|
||||
await configureExtensionSettings(extensionConfig, extension.id, scope);
|
||||
}
|
||||
|
||||
async function configureAllExtensions(scope: ExtensionSettingScope) {
|
||||
const extensionManager = await getExtensionManager();
|
||||
const extensions = extensionManager.getExtensions();
|
||||
|
||||
if (extensions.length === 0) {
|
||||
debugLogger.log('No extensions installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extension of extensions) {
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
extensionConfig &&
|
||||
extensionConfig.settings &&
|
||||
extensionConfig.settings.length > 0
|
||||
) {
|
||||
debugLogger.log(`\nConfiguring settings for "${extension.name}"...`);
|
||||
await configureExtensionSettings(extensionConfig, extension.id, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function configureExtensionSettings(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
scope: ExtensionSettingScope,
|
||||
) {
|
||||
const currentScopedSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
let workspaceSettings: Record<string, string> = {};
|
||||
if (scope === ExtensionSettingScope.USER) {
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!extensionConfig.settings) return;
|
||||
|
||||
for (const setting of extensionConfig.settings) {
|
||||
const currentValue = currentScopedSettings[setting.envVar];
|
||||
const workspaceValue = workspaceSettings[setting.envVar];
|
||||
|
||||
if (workspaceValue !== undefined) {
|
||||
debugLogger.log(
|
||||
`Note: Setting "${setting.name}" is already configured in the workspace scope.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentValue !== undefined) {
|
||||
const response = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'overwrite',
|
||||
message: `Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`,
|
||||
initial: false,
|
||||
});
|
||||
|
||||
if (!response.overwrite) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
setting.envVar,
|
||||
promptForSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
debugLogger,
|
||||
type ExtensionInstallMetadata,
|
||||
@@ -49,7 +50,9 @@ export async function handleLink(args: InstallArgs) {
|
||||
const extension =
|
||||
await extensionManager.installOrUpdateExtension(installMetadata);
|
||||
debugLogger.log(
|
||||
`Extension "${extension.name}" linked successfully and enabled.`,
|
||||
chalk.green(
|
||||
`Extension "${extension.name}" linked successfully and enabled.`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
|
||||
@@ -1,14 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
type ResolvedExtensionSetting,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ExtensionConfig } from '../../config/extension.js';
|
||||
import prompts from 'prompts';
|
||||
import {
|
||||
promptForSetting,
|
||||
updateSetting,
|
||||
type ExtensionSetting,
|
||||
getScopedEnvContents,
|
||||
ExtensionSettingScope,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
|
||||
export interface ConfigLogger {
|
||||
log(message: string): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export type RequestSettingCallback = (
|
||||
setting: ExtensionSetting,
|
||||
) => Promise<string>;
|
||||
export type RequestConfirmationCallback = (message: string) => Promise<boolean>;
|
||||
|
||||
const defaultLogger: ConfigLogger = {
|
||||
log: (message: string) => debugLogger.log(message),
|
||||
error: (message: string) => debugLogger.error(message),
|
||||
};
|
||||
|
||||
const defaultRequestSetting: RequestSettingCallback = async (setting) =>
|
||||
promptForSetting(setting);
|
||||
|
||||
const defaultRequestConfirmation: RequestConfirmationCallback = async (
|
||||
message,
|
||||
) => {
|
||||
const response = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message,
|
||||
initial: false,
|
||||
});
|
||||
return response.confirm;
|
||||
};
|
||||
|
||||
export async function getExtensionManager() {
|
||||
const workspaceDir = process.cwd();
|
||||
@@ -22,16 +62,202 @@ export async function getExtensionManager() {
|
||||
return extensionManager;
|
||||
}
|
||||
|
||||
export async function getExtensionAndManager(name: string) {
|
||||
const extensionManager = await getExtensionManager();
|
||||
export async function getExtensionAndManager(
|
||||
extensionManager: ExtensionManager,
|
||||
name: string,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
) {
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.name === name);
|
||||
|
||||
if (!extension) {
|
||||
debugLogger.error(`Extension "${name}" is not installed.`);
|
||||
return { extension: null, extensionManager: null };
|
||||
logger.error(`Extension "${name}" is not installed.`);
|
||||
return { extension: null };
|
||||
}
|
||||
|
||||
return { extension, extensionManager };
|
||||
return { extension };
|
||||
}
|
||||
|
||||
export async function configureSpecificSetting(
|
||||
extensionManager: ExtensionManager,
|
||||
extensionName: string,
|
||||
settingKey: string,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
) {
|
||||
const { extension } = await getExtensionAndManager(
|
||||
extensionManager,
|
||||
extensionName,
|
||||
logger,
|
||||
);
|
||||
if (!extension) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (!extensionConfig) {
|
||||
logger.error(
|
||||
`Could not find configuration for extension "${extensionName}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
settingKey,
|
||||
requestSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
logger.log(`Setting "${settingKey}" updated.`);
|
||||
}
|
||||
|
||||
export async function configureExtension(
|
||||
extensionManager: ExtensionManager,
|
||||
extensionName: string,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
|
||||
) {
|
||||
const { extension } = await getExtensionAndManager(
|
||||
extensionManager,
|
||||
extensionName,
|
||||
logger,
|
||||
);
|
||||
if (!extension) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
!extensionConfig ||
|
||||
!extensionConfig.settings ||
|
||||
extensionConfig.settings.length === 0
|
||||
) {
|
||||
logger.log(`Extension "${extensionName}" has no settings to configure.`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`Configuring settings for "${extensionName}"...`);
|
||||
await configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
scope,
|
||||
logger,
|
||||
requestSetting,
|
||||
requestConfirmation,
|
||||
);
|
||||
}
|
||||
|
||||
export async function configureAllExtensions(
|
||||
extensionManager: ExtensionManager,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
|
||||
) {
|
||||
const extensions = extensionManager.getExtensions();
|
||||
|
||||
if (extensions.length === 0) {
|
||||
logger.log('No extensions installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extension of extensions) {
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
extensionConfig &&
|
||||
extensionConfig.settings &&
|
||||
extensionConfig.settings.length > 0
|
||||
) {
|
||||
logger.log(`\nConfiguring settings for "${extension.name}"...`);
|
||||
await configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
scope,
|
||||
logger,
|
||||
requestSetting,
|
||||
requestConfirmation,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureExtensionSettings(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
scope: ExtensionSettingScope,
|
||||
logger: ConfigLogger = defaultLogger,
|
||||
requestSetting: RequestSettingCallback = defaultRequestSetting,
|
||||
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
|
||||
) {
|
||||
const currentScopedSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
let workspaceSettings: Record<string, string> = {};
|
||||
if (scope === ExtensionSettingScope.USER) {
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!extensionConfig.settings) return;
|
||||
|
||||
for (const setting of extensionConfig.settings) {
|
||||
const currentValue = currentScopedSettings[setting.envVar];
|
||||
const workspaceValue = workspaceSettings[setting.envVar];
|
||||
|
||||
if (workspaceValue !== undefined) {
|
||||
logger.log(
|
||||
`Note: Setting "${setting.name}" is already configured in the workspace scope.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentValue !== undefined) {
|
||||
const confirmed = await requestConfirmation(
|
||||
`Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
setting.envVar,
|
||||
requestSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getFormattedSettingValue(
|
||||
setting: ResolvedExtensionSetting,
|
||||
): string {
|
||||
if (!setting.value) {
|
||||
return '[not set]';
|
||||
}
|
||||
if (setting.sensitive) {
|
||||
return '***';
|
||||
}
|
||||
return setting.value;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...original,
|
||||
createTransport: vi.fn(),
|
||||
|
||||
MCPServerStatus: {
|
||||
CONNECTED: 'CONNECTED',
|
||||
CONNECTING: 'CONNECTING',
|
||||
@@ -223,4 +224,46 @@ describe('mcp list command', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter servers based on admin allowlist passed in settings', async () => {
|
||||
const settingsWithAllowlist = mergeSettings({}, {}, {}, {}, true);
|
||||
settingsWithAllowlist.admin = {
|
||||
secureModeEnabled: false,
|
||||
extensions: { enabled: true },
|
||||
skills: { enabled: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
config: {
|
||||
'allowed-server': { url: 'http://allowed' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
settingsWithAllowlist.mcpServers = {
|
||||
'allowed-server': { command: 'cmd1' },
|
||||
'forbidden-server': { command: 'cmd2' },
|
||||
};
|
||||
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: settingsWithAllowlist,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers(settingsWithAllowlist);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('allowed-server'),
|
||||
);
|
||||
expect(debugLogger.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('forbidden-server'),
|
||||
);
|
||||
expect(mockedCreateTransport).toHaveBeenCalledWith(
|
||||
'allowed-server',
|
||||
expect.objectContaining({ url: 'http://allowed' }), // Should use admin config
|
||||
false,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
// File for 'gemini mcp list' command
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { type MergedSettings, loadSettings } from '../../config/settings.js';
|
||||
import type { MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import {
|
||||
MCPServerStatus,
|
||||
createTransport,
|
||||
debugLogger,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
@@ -24,18 +26,24 @@ const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
export async function getMcpServersFromConfig(): Promise<
|
||||
Record<string, MCPServerConfig>
|
||||
> {
|
||||
const settings = loadSettings();
|
||||
export async function getMcpServersFromConfig(
|
||||
settings?: MergedSettings,
|
||||
): Promise<{
|
||||
mcpServers: Record<string, MCPServerConfig>;
|
||||
blockedServerNames: string[];
|
||||
}> {
|
||||
if (!settings) {
|
||||
settings = loadSettings().merged;
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings: settings.merged,
|
||||
settings,
|
||||
workspaceDir: process.cwd(),
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const mcpServers = { ...settings.merged.mcpServers };
|
||||
const mcpServers = { ...settings.mcpServers };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
@@ -47,7 +55,11 @@ export async function getMcpServersFromConfig(): Promise<
|
||||
};
|
||||
});
|
||||
}
|
||||
return mcpServers;
|
||||
|
||||
const adminAllowlist = settings.admin?.mcp?.config;
|
||||
const filteredResult = applyAdminAllowlist(mcpServers, adminAllowlist);
|
||||
|
||||
return filteredResult;
|
||||
}
|
||||
|
||||
async function testMCPConnection(
|
||||
@@ -103,12 +115,23 @@ async function getServerStatus(
|
||||
return testMCPConnection(serverName, server);
|
||||
}
|
||||
|
||||
export async function listMcpServers(): Promise<void> {
|
||||
const mcpServers = await getMcpServersFromConfig();
|
||||
export async function listMcpServers(settings?: MergedSettings): Promise<void> {
|
||||
const { mcpServers, blockedServerNames } =
|
||||
await getMcpServersFromConfig(settings);
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
|
||||
if (blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n');
|
||||
}
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
debugLogger.log('No MCP servers configured.');
|
||||
if (blockedServerNames.length === 0) {
|
||||
debugLogger.log('No MCP servers configured.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,11 +177,15 @@ export async function listMcpServers(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule = {
|
||||
interface ListArgs {
|
||||
settings?: MergedSettings;
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule<object, ListArgs> = {
|
||||
command: 'list',
|
||||
describe: 'List all configured MCP servers',
|
||||
handler: async () => {
|
||||
await listMcpServers();
|
||||
handler: async (argv) => {
|
||||
await listMcpServers(argv.settings);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,6 +21,17 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
return {
|
||||
...actualFs,
|
||||
existsSync: vi.fn(actualFs.existsSync),
|
||||
readFileSync: vi.fn(actualFs.readFileSync),
|
||||
writeFileSync: vi.fn(actualFs.writeFileSync),
|
||||
mkdirSync: vi.fn(actualFs.mkdirSync),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
@@ -30,6 +41,14 @@ vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi.fn(() => ({
|
||||
isTrusted: true,
|
||||
source: undefined,
|
||||
})),
|
||||
isFolderTrustEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('mcp remove command', () => {
|
||||
describe('unit tests with mocks', () => {
|
||||
let parser: Argv;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { listCommand } from './skills/list.js';
|
||||
import { enableCommand } from './skills/enable.js';
|
||||
import { disableCommand } from './skills/disable.js';
|
||||
import { installCommand } from './skills/install.js';
|
||||
import { linkCommand } from './skills/link.js';
|
||||
import { uninstallCommand } from './skills/uninstall.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
@@ -27,6 +28,7 @@ export const skillsCommand: CommandModule = {
|
||||
.command(defer(enableCommand, 'skills'))
|
||||
.command(defer(disableCommand, 'skills'))
|
||||
.command(defer(installCommand, 'skills'))
|
||||
.command(defer(linkCommand, 'skills'))
|
||||
.command(defer(uninstallCommand, 'skills'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { handleLink, linkCommand } from './link.js';
|
||||
|
||||
const mockLinkSkill = vi.hoisted(() => vi.fn());
|
||||
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
|
||||
const mockSkillsConsentString = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../utils/skillUtils.js', () => ({
|
||||
linkSkill: mockLinkSkill,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
skillsConsentString: mockSkillsConsentString,
|
||||
}));
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
describe('skills link command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
describe('linkCommand', () => {
|
||||
it('should have correct command and describe', () => {
|
||||
expect(linkCommand.command).toBe('link <path>');
|
||||
expect(linkCommand.describe).toContain('Links an agent skill');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call linkSkill with correct arguments', async () => {
|
||||
const sourcePath = '/source/path';
|
||||
mockLinkSkill.mockResolvedValue([
|
||||
{ name: 'test-skill', location: '/dest/path' },
|
||||
]);
|
||||
|
||||
await handleLink({ path: sourcePath, scope: 'user' });
|
||||
|
||||
expect(mockLinkSkill).toHaveBeenCalledWith(
|
||||
sourcePath,
|
||||
'user',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Successfully linked skills'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle linkSkill failure', async () => {
|
||||
mockLinkSkill.mockRejectedValue(new Error('Link failed'));
|
||||
|
||||
await handleLink({ path: '/some/path' });
|
||||
|
||||
expect(debugLogger.error).toHaveBeenCalledWith('Link failed');
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
skillsConsentString,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import { linkSkill } from '../../utils/skillUtils.js';
|
||||
|
||||
interface LinkArgs {
|
||||
path: string;
|
||||
scope?: 'user' | 'workspace';
|
||||
consent?: boolean;
|
||||
}
|
||||
|
||||
export async function handleLink(args: LinkArgs) {
|
||||
try {
|
||||
const { scope = 'user', consent } = args;
|
||||
|
||||
await linkSkill(
|
||||
args.path,
|
||||
scope,
|
||||
(msg) => debugLogger.log(msg),
|
||||
async (skills, targetDir) => {
|
||||
const consentString = await skillsConsentString(
|
||||
skills,
|
||||
args.path,
|
||||
targetDir,
|
||||
true,
|
||||
);
|
||||
if (consent) {
|
||||
debugLogger.log('You have consented to the following:');
|
||||
debugLogger.log(consentString);
|
||||
return true;
|
||||
}
|
||||
return requestConsentNonInteractive(consentString);
|
||||
},
|
||||
);
|
||||
|
||||
debugLogger.log(chalk.green('\nSuccessfully linked skills.'));
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
await exitCli(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const linkCommand: CommandModule = {
|
||||
command: 'link <path>',
|
||||
describe:
|
||||
'Links an agent skill from a local path. Updates to the source will be reflected immediately.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('path', {
|
||||
describe: 'The local path of the skill to link.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
describe:
|
||||
'The scope to link the skill into. Defaults to "user" (global).',
|
||||
choices: ['user', 'workspace'],
|
||||
default: 'user',
|
||||
})
|
||||
.option('consent', {
|
||||
describe:
|
||||
'Acknowledge the security risks of linking a skill and skip the confirmation prompt.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
})
|
||||
.check((argv) => {
|
||||
if (!argv.path) {
|
||||
throw new Error('The path argument must be provided.');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleLink({
|
||||
path: argv['path'] as string,
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
type ExtensionLoader,
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import { type Settings, createTestMergedSettings } from './settings.js';
|
||||
@@ -1014,7 +1016,9 @@ describe('mergeExcludeTools', () => {
|
||||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(defaultExcludes);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set([...defaultExcludes, ASK_USER_TOOL_NAME]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle settings with excludeTools but no extensions', async () => {
|
||||
@@ -1098,6 +1102,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude all interactive tools in non-interactive mode with explicit default approval mode', async () => {
|
||||
@@ -1118,6 +1123,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude only shell tools in non-interactive mode with auto_edit approval mode', async () => {
|
||||
@@ -1138,9 +1144,10 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude no interactive tools in non-interactive mode with yolo approval mode', async () => {
|
||||
it('should exclude only ask_user in non-interactive mode with yolo approval mode', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
@@ -1158,6 +1165,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => {
|
||||
@@ -1182,9 +1190,10 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should exclude no interactive tools in non-interactive mode with legacy yolo flag', async () => {
|
||||
it('should exclude only ask_user in non-interactive mode with legacy yolo flag', async () => {
|
||||
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
@@ -1195,6 +1204,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in interactive mode regardless of approval mode', async () => {
|
||||
@@ -1219,6 +1229,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
|
||||
expect(excludedTools).not.toContain(ASK_USER_TOOL_NAME);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1431,6 +1442,211 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig with admin.mcp.config', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
const localMcpServers: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@mcp/server-a'],
|
||||
env: { KEY: 'VALUE' },
|
||||
cwd: '/local/cwd',
|
||||
trust: false,
|
||||
},
|
||||
serverB: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@mcp/server-b'],
|
||||
trust: false,
|
||||
},
|
||||
};
|
||||
|
||||
const baseSettings = createTestMergedSettings({
|
||||
mcp: { serverCommand: 'npx -y @mcp/default-server' },
|
||||
mcpServers: localMcpServers,
|
||||
});
|
||||
|
||||
it('should use local configuration if admin allowlist is empty', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
mcp: baseSettings.mcp,
|
||||
mcpServers: localMcpServers,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: {} },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual(localMcpServers);
|
||||
expect(config.getMcpServerCommand()).toBe('npx -y @mcp/default-server');
|
||||
});
|
||||
|
||||
it('should ignore locally configured servers not present in the allowlist', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcp: baseSettings.mcp,
|
||||
mcpServers: localMcpServers,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const mergedServers = config.getMcpServers() ?? {};
|
||||
expect(mergedServers).toHaveProperty('serverA');
|
||||
expect(mergedServers).not.toHaveProperty('serverB');
|
||||
});
|
||||
|
||||
it('should clear command, args, env, and cwd for present servers', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcpServers: localMcpServers,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const serverA = config.getMcpServers()?.['serverA'];
|
||||
expect(serverA).toEqual({
|
||||
...localMcpServers['serverA'],
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
command: undefined,
|
||||
args: undefined,
|
||||
env: undefined,
|
||||
cwd: undefined,
|
||||
httpUrl: undefined,
|
||||
tcp: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not initialize a server if it is in allowlist but missing locally', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverC: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-c.com/sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcpServers: localMcpServers,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const mergedServers = config.getMcpServers() ?? {};
|
||||
expect(mergedServers).not.toHaveProperty('serverC');
|
||||
expect(Object.keys(mergedServers)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should merge local fields and prefer admin tool filters', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
includeTools: ['admin_tool'],
|
||||
},
|
||||
};
|
||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
...localMcpServers['serverA'],
|
||||
includeTools: ['local_tool'],
|
||||
timeout: 1234,
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcpServers: localMcpServersWithTools,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const serverA = (config.getMcpServers() ?? {})['serverA'];
|
||||
expect(serverA).toMatchObject({
|
||||
timeout: 1234,
|
||||
includeTools: ['admin_tool'],
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
});
|
||||
expect(serverA).not.toHaveProperty('command');
|
||||
expect(serverA).not.toHaveProperty('args');
|
||||
expect(serverA).not.toHaveProperty('env');
|
||||
expect(serverA).not.toHaveProperty('cwd');
|
||||
expect(serverA).not.toHaveProperty('httpUrl');
|
||||
expect(serverA).not.toHaveProperty('tcp');
|
||||
});
|
||||
|
||||
it('should use local tool filters when admin does not define them', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const adminAllowlist: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
...localMcpServers['serverA'],
|
||||
includeTools: ['local_tool'],
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
mcpServers: localMcpServersWithTools,
|
||||
admin: {
|
||||
...baseSettings.admin,
|
||||
mcp: { enabled: true, config: adminAllowlist },
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const serverA = config.getMcpServers()?.['serverA'];
|
||||
expect(serverA?.includeTools).toEqual(['local_tool']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig model selection', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
@@ -1467,7 +1683,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('auto-gemini-2.5');
|
||||
expect(config.getModel()).toBe('auto-gemini-3');
|
||||
});
|
||||
|
||||
it('always prefers model from argv', async () => {
|
||||
@@ -1511,7 +1727,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('auto-gemini-2.5');
|
||||
expect(config.getModel()).toBe('auto-gemini-3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1556,12 +1772,12 @@ describe('loadCliConfig folderTrust', () => {
|
||||
expect(config.getFolderTrust()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false by default', async () => {
|
||||
it('should be true 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.getFolderTrust()).toBe(false);
|
||||
expect(config.getFolderTrust()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1777,6 +1993,7 @@ describe('loadCliConfig tool exclusions', () => {
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getExcludeTools()).not.toContain('ask_user');
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
|
||||
@@ -1791,6 +2008,7 @@ describe('loadCliConfig tool exclusions', () => {
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getExcludeTools()).not.toContain('ask_user');
|
||||
});
|
||||
|
||||
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
|
||||
@@ -1805,9 +2023,10 @@ describe('loadCliConfig tool exclusions', () => {
|
||||
expect(config.getExcludeTools()).toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).toContain('replace');
|
||||
expect(config.getExcludeTools()).toContain('write_file');
|
||||
expect(config.getExcludeTools()).toContain('ask_user');
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
|
||||
it('should exclude only ask_user in non-interactive mode with YOLO', async () => {
|
||||
process.stdin.isTTY = false;
|
||||
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -1819,6 +2038,7 @@ describe('loadCliConfig tool exclusions', () => {
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getExcludeTools()).toContain('ask_user');
|
||||
});
|
||||
|
||||
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => {
|
||||
|
||||
@@ -12,11 +12,9 @@ import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { skillsCommand } from '../commands/skills.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import {
|
||||
Config,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
getCurrentGeminiMdFilename,
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
@@ -31,14 +29,20 @@ import {
|
||||
debugLogger,
|
||||
loadServerHierarchicalMemory,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
getVersion,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
getAdminErrorMessage,
|
||||
Config,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HookDefinition,
|
||||
HookEventName,
|
||||
OutputFormat,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -596,6 +600,10 @@ export async function loadCliConfig(
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive) {
|
||||
// ask_user requires user interaction and must be excluded in all
|
||||
// non-interactive modes, regardless of the approval mode.
|
||||
extraExcludes.push(ASK_USER_TOOL_NAME);
|
||||
|
||||
const defaultExcludes = [
|
||||
SHELL_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
@@ -654,9 +662,7 @@ export async function loadCliConfig(
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
const defaultModel = settings.general?.previewFeatures
|
||||
? PREVIEW_GEMINI_MODEL_AUTO
|
||||
: DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
const specifiedModel =
|
||||
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
|
||||
|
||||
@@ -682,6 +688,24 @@ export async function loadCliConfig(
|
||||
? mcpEnablementManager.getEnablementCallbacks()
|
||||
: undefined;
|
||||
|
||||
const adminAllowlist = settings.admin?.mcp?.config;
|
||||
let mcpServerCommand = mcpEnabled ? settings.mcp?.serverCommand : undefined;
|
||||
let mcpServers = mcpEnabled ? settings.mcpServers : {};
|
||||
|
||||
if (mcpEnabled && adminAllowlist && Object.keys(adminAllowlist).length > 0) {
|
||||
const result = applyAdminAllowlist(mcpServers, adminAllowlist);
|
||||
mcpServers = result.mcpServers;
|
||||
mcpServerCommand = undefined;
|
||||
|
||||
if (result.blockedServerNames && result.blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
result.blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
coreEvents.emitConsoleLog('warn', message);
|
||||
}
|
||||
}
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
clientVersion: await getVersion(),
|
||||
@@ -693,7 +717,6 @@ export async function loadCliConfig(
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
debugMode,
|
||||
question,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
@@ -701,8 +724,8 @@ export async function loadCliConfig(
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
|
||||
mcpServers: mcpEnabled ? settings.mcpServers : {},
|
||||
mcpServerCommand,
|
||||
mcpServers,
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
extensionsEnabled,
|
||||
@@ -754,11 +777,11 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
enableEventDrivenScheduler:
|
||||
settings.experimental?.enableEventDrivenScheduler,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
@@ -776,8 +799,6 @@ export async function loadCliConfig(
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
output: {
|
||||
|
||||
@@ -108,6 +108,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
security: { folderTrust: { enabled: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -146,6 +147,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
security: { folderTrust: { enabled: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -182,6 +184,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
security: { folderTrust: { enabled: false } },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -195,7 +198,7 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
(s) => s.envVar === 'TEST_SETTING',
|
||||
);
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting?.value).toBe('[not set]');
|
||||
expect(setting?.value).toBeUndefined();
|
||||
expect(setting?.scope).toBeUndefined();
|
||||
|
||||
// Verify output string does not contain scope
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
}),
|
||||
isTrustedFolder: () => true,
|
||||
getImportFormat: () => 'tree',
|
||||
reloadSkills: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
await extensionManager.start(mockConfig);
|
||||
@@ -208,6 +209,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
getAgentRegistry: () => ({
|
||||
reload: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
reloadSkills: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
await extensionManager.start(mockConfig);
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
type HookEventName,
|
||||
type ResolvedExtensionSetting,
|
||||
coreEvents,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
@@ -70,6 +72,7 @@ import {
|
||||
} from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
import { themeManager } from '../ui/themes/theme-manager.js';
|
||||
import { getFormattedSettingValue } from '../commands/extensions/utils.js';
|
||||
|
||||
interface ExtensionManagerParams {
|
||||
enabledExtensionOverrides?: string[];
|
||||
@@ -648,12 +651,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
resolvedSettings.push({
|
||||
name: setting.name,
|
||||
envVar: setting.envVar,
|
||||
value:
|
||||
value === undefined
|
||||
? '[not set]'
|
||||
: setting.sensitive
|
||||
? '***'
|
||||
: value,
|
||||
value,
|
||||
sensitive: setting.sensitive ?? false,
|
||||
scope,
|
||||
source,
|
||||
@@ -665,12 +663,33 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
if (this.settings.admin.mcp.enabled === false) {
|
||||
config.mcpServers = undefined;
|
||||
} else {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
key,
|
||||
filterMcpConfig(value),
|
||||
]),
|
||||
);
|
||||
// Apply admin allowlist if configured
|
||||
const adminAllowlist = this.settings.admin.mcp.config;
|
||||
if (adminAllowlist && Object.keys(adminAllowlist).length > 0) {
|
||||
const result = applyAdminAllowlist(
|
||||
config.mcpServers,
|
||||
adminAllowlist,
|
||||
);
|
||||
config.mcpServers = result.mcpServers;
|
||||
|
||||
if (result.blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
result.blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
coreEvents.emitConsoleLog('warn', message);
|
||||
}
|
||||
}
|
||||
|
||||
// Then apply local filtering/sanitization
|
||||
if (config.mcpServers) {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
key,
|
||||
filterMcpConfig(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,7 +960,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
scope += ')';
|
||||
}
|
||||
output += `\n ${setting.name}: ${setting.value} ${scope}`;
|
||||
output += `\n ${setting.name}: ${getFormattedSettingValue(setting)} ${scope}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import {
|
||||
ExtensionRegistryClient,
|
||||
type RegistryExtension,
|
||||
} from './extensionRegistryClient.js';
|
||||
import { fetchWithTimeout } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
fetchWithTimeout: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExtensions: RegistryExtension[] = [
|
||||
{
|
||||
id: 'ext1',
|
||||
rank: 1,
|
||||
url: 'https://github.com/test/ext1',
|
||||
fullName: 'test/ext1',
|
||||
repoDescription: 'Test extension 1',
|
||||
stars: 100,
|
||||
lastUpdated: '2025-01-01T00:00:00Z',
|
||||
extensionName: 'extension-one',
|
||||
extensionVersion: '1.0.0',
|
||||
extensionDescription: 'First test extension',
|
||||
avatarUrl: 'https://example.com/avatar1.png',
|
||||
hasMCP: true,
|
||||
hasContext: false,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: 'mit',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'ext2',
|
||||
rank: 2,
|
||||
url: 'https://github.com/test/ext2',
|
||||
fullName: 'test/ext2',
|
||||
repoDescription: 'Test extension 2',
|
||||
stars: 50,
|
||||
lastUpdated: '2025-01-02T00:00:00Z',
|
||||
extensionName: 'extension-two',
|
||||
extensionVersion: '0.5.0',
|
||||
extensionDescription: 'Second test extension',
|
||||
avatarUrl: 'https://example.com/avatar2.png',
|
||||
hasMCP: false,
|
||||
hasContext: true,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: 'apache-2.0',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'ext3',
|
||||
rank: 3,
|
||||
url: 'https://github.com/test/ext3',
|
||||
fullName: 'test/ext3',
|
||||
repoDescription: 'Test extension 3',
|
||||
stars: 10,
|
||||
lastUpdated: '2025-01-03T00:00:00Z',
|
||||
extensionName: 'extension-three',
|
||||
extensionVersion: '0.1.0',
|
||||
extensionDescription: 'Third test extension',
|
||||
avatarUrl: 'https://example.com/avatar3.png',
|
||||
hasMCP: true,
|
||||
hasContext: true,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: 'gpl-3.0',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe('ExtensionRegistryClient', () => {
|
||||
let client: ExtensionRegistryClient;
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
ExtensionRegistryClient.resetCache();
|
||||
client = new ExtensionRegistryClient();
|
||||
fetchMock = fetchWithTimeout as Mock;
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch and return extensions with pagination (default ranking)', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtensions(1, 2);
|
||||
expect(result.extensions).toHaveLength(2);
|
||||
expect(result.extensions[0].id).toBe('ext1'); // rank 1
|
||||
expect(result.extensions[1].id).toBe('ext2'); // rank 2
|
||||
expect(result.total).toBe(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://geminicli.com/extensions.json',
|
||||
10000,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return extensions sorted alphabetically', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtensions(1, 3, 'alphabetical');
|
||||
expect(result.extensions).toHaveLength(3);
|
||||
expect(result.extensions[0].id).toBe('ext1');
|
||||
expect(result.extensions[1].id).toBe('ext3');
|
||||
expect(result.extensions[2].id).toBe('ext2');
|
||||
});
|
||||
|
||||
it('should return the second page of extensions', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtensions(2, 2);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].id).toBe('ext3');
|
||||
expect(result.total).toBe(3);
|
||||
});
|
||||
|
||||
it('should search extensions by name', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('one');
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].id).toBe('ext1');
|
||||
});
|
||||
|
||||
it('should search extensions by description', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('Second');
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].id).toBe('ext2');
|
||||
});
|
||||
|
||||
it('should get an extension by ID', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtension('ext2');
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe('ext2');
|
||||
});
|
||||
|
||||
it('should return undefined if extension not found', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtension('non-existent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should cache the fetch result', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
await client.getExtensions();
|
||||
await client.getExtensions();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should share the fetch result across instances', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const client1 = new ExtensionRegistryClient();
|
||||
const client2 = new ExtensionRegistryClient();
|
||||
|
||||
await client1.getExtensions();
|
||||
await client2.getExtensions();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if fetch fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
statusText: 'Not Found',
|
||||
});
|
||||
|
||||
await expect(client.getExtensions()).rejects.toThrow(
|
||||
'Failed to fetch extensions: Not Found',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout } from '@google/gemini-cli-core';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
|
||||
export interface RegistryExtension {
|
||||
id: string;
|
||||
rank: number;
|
||||
url: string;
|
||||
fullName: string;
|
||||
repoDescription: string;
|
||||
stars: number;
|
||||
lastUpdated: string;
|
||||
extensionName: string;
|
||||
extensionVersion: string;
|
||||
extensionDescription: string;
|
||||
avatarUrl: string;
|
||||
hasMCP: boolean;
|
||||
hasContext: boolean;
|
||||
hasHooks: boolean;
|
||||
hasSkills: boolean;
|
||||
hasCustomCommands: boolean;
|
||||
isGoogleOwned: boolean;
|
||||
licenseKey: string;
|
||||
}
|
||||
|
||||
export class ExtensionRegistryClient {
|
||||
private static readonly REGISTRY_URL =
|
||||
'https://geminicli.com/extensions.json';
|
||||
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
|
||||
|
||||
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
|
||||
|
||||
/** @internal */
|
||||
static resetCache() {
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
}
|
||||
|
||||
async getExtensions(
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
orderBy: 'ranking' | 'alphabetical' = 'ranking',
|
||||
): Promise<{ extensions: RegistryExtension[]; total: number }> {
|
||||
const allExtensions = [...(await this.fetchAllExtensions())];
|
||||
|
||||
switch (orderBy) {
|
||||
case 'ranking':
|
||||
allExtensions.sort((a, b) => a.rank - b.rank);
|
||||
break;
|
||||
case 'alphabetical':
|
||||
allExtensions.sort((a, b) =>
|
||||
a.extensionName.localeCompare(b.extensionName),
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
const _exhaustiveCheck: never = orderBy;
|
||||
throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = startIndex + limit;
|
||||
return {
|
||||
extensions: allExtensions.slice(startIndex, endIndex),
|
||||
total: allExtensions.length,
|
||||
};
|
||||
}
|
||||
|
||||
async searchExtensions(query: string): Promise<RegistryExtension[]> {
|
||||
const allExtensions = await this.fetchAllExtensions();
|
||||
if (!query.trim()) {
|
||||
return allExtensions;
|
||||
}
|
||||
|
||||
const fzf = new AsyncFzf(allExtensions, {
|
||||
selector: (ext: RegistryExtension) =>
|
||||
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
|
||||
fuzzy: 'v2',
|
||||
});
|
||||
const results = await fzf.find(query);
|
||||
return results.map((r: { item: RegistryExtension }) => r.item);
|
||||
}
|
||||
|
||||
async getExtension(id: string): Promise<RegistryExtension | undefined> {
|
||||
const allExtensions = await this.fetchAllExtensions();
|
||||
return allExtensions.find((ext) => ext.id === id);
|
||||
}
|
||||
|
||||
private async fetchAllExtensions(): Promise<RegistryExtension[]> {
|
||||
if (ExtensionRegistryClient.fetchPromise) {
|
||||
return ExtensionRegistryClient.fetchPromise;
|
||||
}
|
||||
|
||||
ExtensionRegistryClient.fetchPromise = (async () => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
ExtensionRegistryClient.REGISTRY_URL,
|
||||
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so that subsequent calls can try again
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
return ExtensionRegistryClient.fetchPromise;
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,19 @@ export async function skillsConsentString(
|
||||
skills: SkillDefinition[],
|
||||
source: string,
|
||||
targetDir?: string,
|
||||
isLink = false,
|
||||
): Promise<string> {
|
||||
const action = isLink ? 'Linking' : 'Installing';
|
||||
const output: string[] = [];
|
||||
output.push(`Installing agent skill(s) from "${source}".`);
|
||||
output.push('\nThe following agent skill(s) will be installed:\n');
|
||||
output.push(`${action} agent skill(s) from "${source}".`);
|
||||
output.push(
|
||||
`\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`,
|
||||
);
|
||||
output.push(...(await renderSkillsList(skills)));
|
||||
|
||||
if (targetDir) {
|
||||
output.push(`Install Destination: ${targetDir}`);
|
||||
const destLabel = isLink ? 'Link' : 'Install';
|
||||
output.push(`${destLabel} Destination: ${targetDir}`);
|
||||
}
|
||||
output.push('\n' + SKILLS_WARNING_MESSAGE);
|
||||
|
||||
|
||||
@@ -786,6 +786,23 @@ describe('extensionSettings', () => {
|
||||
expect(await userKeychain.getSecret('VAR2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should delete a non-sensitive setting if the new value is empty', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).not.toContain('VAR1=');
|
||||
});
|
||||
|
||||
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
// Ensure it doesn't exist first
|
||||
@@ -804,5 +821,74 @@ describe('extensionSettings', () => {
|
||||
);
|
||||
// Should complete without error
|
||||
});
|
||||
|
||||
it('should throw error if env var name contains invalid characters', async () => {
|
||||
const securityConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's2', description: 'd2', envVar: 'VAR-BAD' }],
|
||||
};
|
||||
mockRequestSetting.mockResolvedValue('value');
|
||||
|
||||
await expect(
|
||||
updateSetting(
|
||||
securityConfig,
|
||||
'12345',
|
||||
'VAR-BAD',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
),
|
||||
).rejects.toThrow(/Invalid environment variable name/);
|
||||
});
|
||||
|
||||
it('should throw error if env var value contains newlines', async () => {
|
||||
mockRequestSetting.mockResolvedValue('value\nwith\nnewlines');
|
||||
|
||||
await expect(
|
||||
updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
),
|
||||
).rejects.toThrow(/Invalid environment variable value/);
|
||||
});
|
||||
|
||||
it('should quote values with spaces', async () => {
|
||||
mockRequestSetting.mockResolvedValue('value with spaces');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1="value with spaces"');
|
||||
});
|
||||
|
||||
it('should escape quotes in values', async () => {
|
||||
mockRequestSetting.mockResolvedValue('value with "quotes"');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR1',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1="value with \\"quotes\\""');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +130,19 @@ export async function maybePromptForSettings(
|
||||
function formatEnvContent(settings: Record<string, string>): string {
|
||||
let envContent = '';
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const formattedValue = value.includes(' ') ? `"${value}"` : value;
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
throw new Error(
|
||||
`Invalid environment variable name: "${key}". Must contain only alphanumeric characters and underscores.`,
|
||||
);
|
||||
}
|
||||
if (value.includes('\n') || value.includes('\r')) {
|
||||
throw new Error(
|
||||
`Invalid environment variable value for "${key}". Values cannot contain newlines.`,
|
||||
);
|
||||
}
|
||||
const formattedValue = value.includes(' ')
|
||||
? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
: value;
|
||||
envContent += `${key}=${formattedValue}\n`;
|
||||
}
|
||||
return envContent;
|
||||
@@ -251,7 +263,11 @@ export async function updateSetting(
|
||||
}
|
||||
|
||||
const parsedEnv = dotenv.parse(envContent);
|
||||
parsedEnv[settingToUpdate.envVar] = newValue;
|
||||
if (!newValue) {
|
||||
delete parsedEnv[settingToUpdate.envVar];
|
||||
} else {
|
||||
parsedEnv[settingToUpdate.envVar] = newValue;
|
||||
}
|
||||
|
||||
// We only want to write back the variables that are not sensitive.
|
||||
const nonSensitiveSettings: Record<string, string> = {};
|
||||
|
||||
@@ -80,6 +80,7 @@ export enum Command {
|
||||
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
|
||||
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
|
||||
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning',
|
||||
|
||||
// App Controls
|
||||
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
|
||||
@@ -281,6 +282,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
|
||||
{ key: 'tab', shift: false },
|
||||
],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }],
|
||||
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
|
||||
[Command.SHOW_MORE_LINES]: [
|
||||
@@ -288,7 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 's', ctrl: true },
|
||||
],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
[Command.RESTART_APP]: [{ key: 'r' }],
|
||||
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
|
||||
@@ -405,6 +407,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.UNFOCUS_BACKGROUND_SHELL,
|
||||
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
|
||||
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
|
||||
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
|
||||
Command.FOCUS_SHELL_INPUT,
|
||||
Command.UNFOCUS_SHELL_INPUT,
|
||||
Command.CLEAR_SCREEN,
|
||||
@@ -496,16 +499,23 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
|
||||
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]:
|
||||
'Confirm selection in background shell list.',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]:
|
||||
'Toggle current background shell visibility.',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.',
|
||||
[Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]:
|
||||
'Move focus from background shell to Gemini.',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]:
|
||||
'Move focus from background shell list to Gemini.',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to unfocus background shell via Tab.',
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to unfocus shell input via Tab.',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
|
||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
||||
[Command.RESTART_APP]: 'Restart the application.',
|
||||
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',
|
||||
|
||||
@@ -323,116 +323,65 @@ describe('Policy Engine Integration Tests', () => {
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow write_file to plans directory in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
describe.each(['write_file', 'replace'])(
|
||||
'Plan Mode policy for %s',
|
||||
(toolName) => {
|
||||
it(`should allow ${toolName} to plans directory`, async () => {
|
||||
const settings: Settings = {};
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
// Valid plan file paths
|
||||
const validPaths = [
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md',
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md',
|
||||
'/home/user/.gemini/tmp/new-temp_dir_123/plans/plan.md', // new style of temp directory
|
||||
];
|
||||
|
||||
// Valid plan file path (64-char hex hash, .md extension, safe filename)
|
||||
const validPlanPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: validPlanPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
for (const file_path of validPaths) {
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: toolName, args: { file_path } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
}
|
||||
});
|
||||
|
||||
// Valid plan with underscore in filename
|
||||
const validPlanPath2 =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: validPlanPath2 } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
it(`should deny ${toolName} outside plans directory`, async () => {
|
||||
const settings: Settings = {};
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
it('should deny write_file outside plans directory in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
const invalidPaths = [
|
||||
'/project/src/file.ts', // Workspace
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md', // Subdirectory
|
||||
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
|
||||
];
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Write to workspace (not plans dir) should be denied
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: '/project/src/file.ts' } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Write to plans dir but wrong extension should be denied
|
||||
const wrongExtPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: wrongExtPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Path traversal attempt should be denied (filename contains /)
|
||||
const traversalPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: traversalPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Invalid hash length should be denied
|
||||
const shortHashPath = '/home/user/.gemini/tmp/abc123/plans/plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: shortHashPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should deny write_file to subdirectories in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Write to subdirectory should be denied
|
||||
const subdirPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: subdirPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
for (const file_path of invalidPaths) {
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: toolName, args: { file_path } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
@@ -485,8 +434,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server
|
||||
|
||||
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 50 in default tier → 1.05
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
|
||||
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
|
||||
|
||||
// Verify the engine applies these priorities correctly
|
||||
expect(
|
||||
@@ -641,8 +590,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier)
|
||||
|
||||
const globRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 50 in default tier → 1.05
|
||||
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
|
||||
// Priority 70 in default tier → 1.07
|
||||
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
|
||||
|
||||
// The PolicyEngine will sort these by priority when it's created
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
@@ -67,21 +67,27 @@ import {
|
||||
getSystemSettingsPath,
|
||||
getSystemDefaultsPath,
|
||||
type Settings,
|
||||
saveSettings,
|
||||
type SettingsFile,
|
||||
saveSettings,
|
||||
getDefaultsFromSchema,
|
||||
loadEnvironment,
|
||||
migrateDeprecatedSettings,
|
||||
SettingScope,
|
||||
LoadedSettings,
|
||||
sanitizeEnvVar,
|
||||
} from './settings.js';
|
||||
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import {
|
||||
getSettingsSchema,
|
||||
MergeStrategy,
|
||||
type SettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
import { createMockSettings } from '../test-utils/settings.js';
|
||||
|
||||
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
||||
// Use the (mocked) GEMINI_DIR for consistency
|
||||
@@ -105,7 +111,7 @@ vi.mock('fs', async (importOriginal) => {
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: (p: string) => p,
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -119,9 +125,11 @@ const mockCoreEvents = vi.hoisted(() => ({
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const os = await import('node:os');
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: mockCoreEvents,
|
||||
homedir: vi.fn(() => os.homedir()),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1460,6 +1468,44 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => {
|
||||
const mockHomeDir = '/mock/home/user';
|
||||
const mockSymlinkDir = '/mock/symlink/to/home';
|
||||
const mockWorkspaceSettingsPath = path.join(
|
||||
mockSymlinkDir,
|
||||
GEMINI_DIR,
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
vi.mocked(osActual.homedir).mockReturnValue(mockHomeDir);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
|
||||
const pStr = p.toString();
|
||||
const resolved = path.resolve(pStr);
|
||||
if (
|
||||
resolved === path.resolve(mockSymlinkDir) ||
|
||||
resolved === path.resolve(mockHomeDir)
|
||||
) {
|
||||
return mockHomeDir;
|
||||
}
|
||||
return pStr;
|
||||
});
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: string) =>
|
||||
// Only return true for workspace settings path to see if it gets loaded
|
||||
p === mockWorkspaceSettingsPath,
|
||||
);
|
||||
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('excludedProjectEnvVars integration', () => {
|
||||
@@ -1706,6 +1752,7 @@ describe('Settings Loading and Merging', () => {
|
||||
isFolderTrustEnabled = true,
|
||||
isWorkspaceTrustedValue = true as boolean | undefined,
|
||||
}) {
|
||||
delete process.env['GEMINI_API_KEY']; // reset
|
||||
delete process.env['TESTTEST']; // reset
|
||||
const geminiEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
|
||||
@@ -1739,7 +1786,8 @@ describe('Settings Loading and Merging', () => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (normalizedP === geminiEnvPath) return 'TESTTEST=1234';
|
||||
if (normalizedP === geminiEnvPath)
|
||||
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
@@ -1753,6 +1801,7 @@ describe('Settings Loading and Merging', () => {
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
});
|
||||
|
||||
it('does not load env files from untrusted spaces', () => {
|
||||
@@ -1777,6 +1826,36 @@ describe('Settings Loading and Merging', () => {
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
|
||||
});
|
||||
|
||||
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
settings.merged.tools.sandbox = true;
|
||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
||||
|
||||
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
// TESTTEST is NOT in the whitelist, so it should be blocked.
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled via CLI flag', () => {
|
||||
const originalArgv = [...process.argv];
|
||||
process.argv.push('-s');
|
||||
try {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
// Ensure sandbox is NOT in settings to test argv sniffing
|
||||
settings.merged.tools.sandbox = undefined;
|
||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1935,29 +2014,7 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const loadedSettings = new LoadedSettings(
|
||||
{
|
||||
path: getSystemSettingsPath(),
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: getSystemDefaultsPath(),
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
},
|
||||
{
|
||||
path: USER_SETTINGS_PATH,
|
||||
settings: userSettingsContent as unknown as Settings,
|
||||
originalSettings: userSettingsContent as unknown as Settings,
|
||||
},
|
||||
{
|
||||
path: MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
},
|
||||
true,
|
||||
);
|
||||
const loadedSettings = createMockSettings(userSettingsContent);
|
||||
|
||||
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
|
||||
|
||||
@@ -2021,7 +2078,7 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => {
|
||||
it('should migrate disableUpdateNag to enableAutoUpdateNotification in memory but not save for system and system defaults settings', () => {
|
||||
const systemSettingsContent = {
|
||||
general: {
|
||||
disableUpdateNag: true,
|
||||
@@ -2046,9 +2103,10 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const feedbackSpy = mockCoreEvents.emitFeedback;
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify system settings were migrated
|
||||
// Verify system settings were migrated in memory
|
||||
expect(settings.system.settings.general).toHaveProperty(
|
||||
'enableAutoUpdateNotification',
|
||||
);
|
||||
@@ -2058,7 +2116,7 @@ describe('Settings Loading and Merging', () => {
|
||||
],
|
||||
).toBe(false);
|
||||
|
||||
// Verify system defaults settings were migrated
|
||||
// Verify system defaults settings were migrated in memory
|
||||
expect(settings.systemDefaults.settings.general).toHaveProperty(
|
||||
'enableAutoUpdateNotification',
|
||||
);
|
||||
@@ -2070,6 +2128,74 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
// Merged should also reflect it (system overrides defaults, but both are migrated)
|
||||
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
|
||||
|
||||
// Verify it was NOT saved back to disk
|
||||
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
|
||||
getSystemSettingsPath(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
|
||||
getSystemDefaultsPath(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Verify warnings were shown
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The system configuration contains deprecated settings',
|
||||
),
|
||||
);
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The system default configuration contains deprecated settings',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate experimental agent settings in system scope in memory but not save', () => {
|
||||
const systemSettingsContent = {
|
||||
experimental: {
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const feedbackSpy = mockCoreEvents.emitFeedback;
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify it was migrated in memory
|
||||
expect(settings.system.settings.agents?.overrides).toMatchObject({
|
||||
codebase_investigator: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify it was NOT saved back to disk
|
||||
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
|
||||
getSystemSettingsPath(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Verify warnings were shown
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The system configuration contains deprecated settings: [experimental.codebaseInvestigatorSettings]',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate experimental agent settings to agents overrides', () => {
|
||||
@@ -2126,11 +2252,8 @@ describe('Settings Loading and Merging', () => {
|
||||
describe('saveSettings', () => {
|
||||
it('should save settings using updateSettingsFilePreservingFormat', () => {
|
||||
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
|
||||
const settingsFile = {
|
||||
path: '/mock/settings.json',
|
||||
settings: { ui: { theme: 'dark' } },
|
||||
originalSettings: { ui: { theme: 'dark' } },
|
||||
} as unknown as SettingsFile;
|
||||
const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user;
|
||||
settingsFile.path = '/mock/settings.json';
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2144,11 +2267,8 @@ describe('Settings Loading and Merging', () => {
|
||||
const mockFsMkdirSync = vi.mocked(fs.mkdirSync);
|
||||
mockFsExistsSync.mockReturnValue(false);
|
||||
|
||||
const settingsFile = {
|
||||
path: '/mock/new/dir/settings.json',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
const settingsFile = createMockSettings({}).user;
|
||||
settingsFile.path = '/mock/new/dir/settings.json';
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2165,11 +2285,8 @@ describe('Settings Loading and Merging', () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const settingsFile = {
|
||||
path: '/mock/settings.json',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
const settingsFile = createMockSettings({}).user;
|
||||
settingsFile.path = '/mock/settings.json';
|
||||
|
||||
saveSettings(settingsFile);
|
||||
|
||||
@@ -2216,8 +2333,11 @@ describe('Settings Loading and Merging', () => {
|
||||
// 2. Now, set remote admin settings.
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
mcpSetting: { mcpEnabled: false, mcpConfig: {} },
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: { extensionsEnabled: false },
|
||||
unmanagedCapabilitiesEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Verify that remote admin settings take precedence.
|
||||
@@ -2257,8 +2377,11 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const newRemoteSettings = {
|
||||
strictModeDisabled: false,
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
mcpSetting: { mcpEnabled: false, mcpConfig: {} },
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: { extensionsEnabled: false },
|
||||
unmanagedCapabilitiesEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
loadedSettings.setRemoteAdminSettings(newRemoteSettings);
|
||||
@@ -2269,13 +2392,6 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
// Non-admin settings should remain untouched
|
||||
expect(loadedSettings.merged.ui?.theme).toBe('initial-theme');
|
||||
|
||||
// Verify that calling setRemoteAdminSettings with partial data overwrites previous remote settings
|
||||
// and missing properties revert to schema defaults.
|
||||
loadedSettings.setRemoteAdminSettings({ strictModeDisabled: true });
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); // Defaulting to false if missing
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Defaulting to false if missing
|
||||
});
|
||||
|
||||
it('should correctly handle undefined remote admin settings', () => {
|
||||
@@ -2307,82 +2423,26 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly handle missing properties in remote admin settings', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const systemSettingsContent = {
|
||||
admin: {
|
||||
secureModeEnabled: true,
|
||||
it('should un-nest MCP configuration from remote settings', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
const mcpServers: Record<string, MCPServerConfig> = {
|
||||
'admin-server': {
|
||||
url: 'http://admin-mcp.com',
|
||||
type: 'sse',
|
||||
trust: true,
|
||||
},
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {
|
||||
mcpEnabled: true,
|
||||
mcpConfig: {
|
||||
mcpServers,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
// Ensure initial state from defaults (as file-based admin settings are ignored)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
|
||||
// Set remote settings with only strictModeDisabled (false -> secureModeEnabled: true)
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
});
|
||||
|
||||
// Verify secureModeEnabled is updated, others default to false
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Set remote settings with only mcpSetting.mcpEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: { mcpEnabled: false },
|
||||
});
|
||||
|
||||
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled defaults to true if strictModeDisabled is missing)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Set remote settings with only cliFeatureSetting.extensionsSetting.extensionsEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: { extensionsSetting: { extensionsEnabled: false } },
|
||||
});
|
||||
|
||||
// Verify extensionsEnabled is updated, others remain defaults
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Verify that missing strictModeDisabled falls back to secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
secureModeEnabled: false,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
secureModeEnabled: true,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
|
||||
// Verify strictModeDisabled takes precedence over secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: false,
|
||||
secureModeEnabled: false,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
strictModeDisabled: true,
|
||||
secureModeEnabled: true,
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers);
|
||||
});
|
||||
|
||||
it('should set skills based on unmanagedCapabilitiesEnabled', () => {
|
||||
@@ -2402,51 +2462,6 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin.skills?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should default mcp.enabled to false if mcpSetting is present but mcpEnabled is undefined', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should default extensions.enabled to false if extensionsSetting is present but extensionsEnabled is undefined', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: {},
|
||||
},
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should force secureModeEnabled to true if undefined, overriding schema defaults', () => {
|
||||
// Mock schema to have secureModeEnabled default to false to verify the override
|
||||
const originalSchema = getSettingsSchema();
|
||||
const modifiedSchema = JSON.parse(JSON.stringify(originalSchema));
|
||||
if (modifiedSchema.admin?.properties?.secureModeEnabled) {
|
||||
modifiedSchema.admin.properties.secureModeEnabled.default = false;
|
||||
}
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(modifiedSchema);
|
||||
|
||||
try {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(() => '{}');
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Pass a non-empty object that doesn't have strictModeDisabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
|
||||
// It should be forced to true by the logic (default secure), overriding the mock default of false
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
} finally {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(originalSchema);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle completely empty remote admin settings response', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
@@ -2496,4 +2511,391 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security and Sandbox', () => {
|
||||
let originalArgv: string[];
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalArgv = [...process.argv];
|
||||
originalEnv = { ...process.env };
|
||||
// Clear relevant env vars
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['GOOGLE_API_KEY'];
|
||||
delete process.env['GOOGLE_CLOUD_PROJECT'];
|
||||
delete process.env['GOOGLE_CLOUD_LOCATION'];
|
||||
delete process.env['CLOUD_SHELL'];
|
||||
delete process.env['MALICIOUS_VAR'];
|
||||
delete process.env['FOO'];
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('sandbox detection', () => {
|
||||
it('should detect sandbox when -s is a real flag', () => {
|
||||
process.argv = ['node', 'gemini', '-s', 'some prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'FOO=bar\nGEMINI_API_KEY=secret',
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// If sandboxed and untrusted, FOO should NOT be loaded, but GEMINI_API_KEY should be.
|
||||
expect(process.env['FOO']).toBeUndefined();
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('secret');
|
||||
});
|
||||
|
||||
it('should detect sandbox when --sandbox is a real flag', () => {
|
||||
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('secret');
|
||||
});
|
||||
|
||||
it('should ignore sandbox flags if they appear after --', () => {
|
||||
process.argv = ['node', 'gemini', '--', '-s', 'some prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT be tricked by positional arguments that look like flags', () => {
|
||||
process.argv = ['node', 'gemini', 'my -s prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=secret');
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('env var sanitization', () => {
|
||||
it('should strictly enforce whitelist in untrusted/sandboxed mode', () => {
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(`
|
||||
GEMINI_API_KEY=secret-key
|
||||
MALICIOUS_VAR=should-be-ignored
|
||||
GOOGLE_API_KEY=another-secret
|
||||
`);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('secret-key');
|
||||
expect(process.env['GOOGLE_API_KEY']).toBe('another-secret');
|
||||
expect(process.env['MALICIOUS_VAR']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should sanitize shell injection characters in whitelisted env vars in untrusted mode', () => {
|
||||
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
|
||||
const maliciousPayload = 'key-$(whoami)-`id`-&|;><*?[]{}';
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
`GEMINI_API_KEY=${maliciousPayload}`,
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// sanitizeEnvVar: value.replace(/[^a-zA-Z0-9\-_./]/g, '')
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('key-whoami-id-');
|
||||
});
|
||||
|
||||
it('should allow . and / in whitelisted env vars but sanitize other characters in untrusted mode', () => {
|
||||
process.argv = ['node', 'gemini', '--sandbox', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
|
||||
const complexPayload = 'secret-123/path.to/somewhere;rm -rf /';
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
`GEMINI_API_KEY=${complexPayload}`,
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe(
|
||||
'secret-123/path.to/somewhererm-rf/',
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT sanitize variables from trusted sources', () => {
|
||||
process.argv = ['node', 'gemini', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('FOO=$(bar)');
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
// Trusted source, no sanitization
|
||||
expect(process.env['FOO']).toBe('$(bar)');
|
||||
});
|
||||
|
||||
it('should load environment variables normally when workspace is TRUSTED even if "sandboxed"', () => {
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) =>
|
||||
path.toString().endsWith('.env'),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(`
|
||||
GEMINI_API_KEY=un-sanitized;key!
|
||||
MALICIOUS_VAR=allowed-because-trusted
|
||||
`);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('un-sanitized;key!');
|
||||
expect(process.env['MALICIOUS_VAR']).toBe('allowed-because-trusted');
|
||||
});
|
||||
|
||||
it('should sanitize value in sanitizeEnvVar helper', () => {
|
||||
expect(sanitizeEnvVar('$(calc)')).toBe('calc');
|
||||
expect(sanitizeEnvVar('`rm -rf /`')).toBe('rm-rf/');
|
||||
expect(sanitizeEnvVar('normal-project-123')).toBe('normal-project-123');
|
||||
expect(sanitizeEnvVar('us-central1')).toBe('us-central1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud Shell security', () => {
|
||||
it('should handle Cloud Shell special defaults securely when untrusted', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
// No .env file
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
|
||||
});
|
||||
|
||||
it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'GOOGLE_CLOUD_PROJECT=attacker-project;inject',
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe(
|
||||
'attacker-projectinject',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoadedSettings Isolation and Serializability', () => {
|
||||
let loadedSettings: LoadedSettings;
|
||||
|
||||
interface TestData {
|
||||
a: {
|
||||
b: number;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Create a minimal LoadedSettings instance
|
||||
const emptyScope = {
|
||||
path: '/mock/settings.json',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
} as unknown as SettingsFile;
|
||||
|
||||
loadedSettings = new LoadedSettings(
|
||||
emptyScope, // system
|
||||
emptyScope, // systemDefaults
|
||||
{ ...emptyScope }, // user
|
||||
emptyScope, // workspace
|
||||
true, // isTrusted
|
||||
);
|
||||
});
|
||||
|
||||
describe('setValue Isolation', () => {
|
||||
it('should isolate state between settings and originalSettings', () => {
|
||||
const complexValue: TestData = { a: { b: 1 } };
|
||||
loadedSettings.setValue(SettingScope.User, 'test', complexValue);
|
||||
|
||||
const userSettings = loadedSettings.forScope(SettingScope.User);
|
||||
const settingsValue = (userSettings.settings as Record<string, unknown>)[
|
||||
'test'
|
||||
] as TestData;
|
||||
const originalValue = (
|
||||
userSettings.originalSettings as Record<string, unknown>
|
||||
)['test'] as TestData;
|
||||
|
||||
// Verify they are equal but different references
|
||||
expect(settingsValue).toEqual(complexValue);
|
||||
expect(originalValue).toEqual(complexValue);
|
||||
expect(settingsValue).not.toBe(complexValue);
|
||||
expect(originalValue).not.toBe(complexValue);
|
||||
expect(settingsValue).not.toBe(originalValue);
|
||||
|
||||
// Modify the in-memory setting object
|
||||
settingsValue.a.b = 2;
|
||||
|
||||
// originalSettings should NOT be affected
|
||||
expect(originalValue.a.b).toBe(1);
|
||||
});
|
||||
|
||||
it('should not share references between settings and originalSettings (original servers test)', () => {
|
||||
const mcpServers = {
|
||||
'test-server': { command: 'echo' },
|
||||
};
|
||||
|
||||
loadedSettings.setValue(SettingScope.User, 'mcpServers', mcpServers);
|
||||
|
||||
// Modify the original object
|
||||
delete (mcpServers as Record<string, unknown>)['test-server'];
|
||||
|
||||
// The settings in LoadedSettings should still have the server
|
||||
const userSettings = loadedSettings.forScope(SettingScope.User);
|
||||
expect(
|
||||
(userSettings.settings.mcpServers as Record<string, unknown>)[
|
||||
'test-server'
|
||||
],
|
||||
).toBeDefined();
|
||||
expect(
|
||||
(userSettings.originalSettings.mcpServers as Record<string, unknown>)[
|
||||
'test-server'
|
||||
],
|
||||
).toBeDefined();
|
||||
|
||||
// They should also be different objects from each other
|
||||
expect(userSettings.settings.mcpServers).not.toBe(
|
||||
userSettings.originalSettings.mcpServers,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setValue Serializability', () => {
|
||||
it('should preserve Map/Set types (via structuredClone)', () => {
|
||||
const mapValue = { myMap: new Map([['key', 'value']]) };
|
||||
loadedSettings.setValue(SettingScope.User, 'test', mapValue);
|
||||
|
||||
const userSettings = loadedSettings.forScope(SettingScope.User);
|
||||
const settingsValue = (userSettings.settings as Record<string, unknown>)[
|
||||
'test'
|
||||
] as { myMap: Map<string, string> };
|
||||
|
||||
// Map is preserved by structuredClone
|
||||
expect(settingsValue.myMap).toBeInstanceOf(Map);
|
||||
expect(settingsValue.myMap.get('key')).toBe('value');
|
||||
|
||||
// But it should be a different reference
|
||||
expect(settingsValue.myMap).not.toBe(mapValue.myMap);
|
||||
});
|
||||
|
||||
it('should handle circular references (structuredClone supports them, but deepMerge may not)', () => {
|
||||
const circular: Record<string, unknown> = { a: 1 };
|
||||
circular['self'] = circular;
|
||||
|
||||
// structuredClone(circular) works, but LoadedSettings.setValue calls
|
||||
// computeMergedSettings() -> customDeepMerge() which blows up on circularity.
|
||||
expect(() => {
|
||||
loadedSettings.setValue(SettingScope.User, 'test', circular);
|
||||
}).toThrow(/Maximum call stack size exceeded/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+271
-160
@@ -16,7 +16,7 @@ import {
|
||||
Storage,
|
||||
coreEvents,
|
||||
homedir,
|
||||
type FetchAdminControlsResponse,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
@@ -77,6 +77,21 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
||||
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
const AUTH_ENV_VAR_WHITELIST = [
|
||||
'GEMINI_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_LOCATION',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sanitizes an environment variable value to prevent shell injection.
|
||||
* Restricts values to a safe character set: alphanumeric, -, _, ., /
|
||||
*/
|
||||
export function sanitizeEnvVar(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9\-_./]/g, '');
|
||||
}
|
||||
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
||||
@@ -179,6 +194,7 @@ export interface SettingsFile {
|
||||
originalSettings: Settings;
|
||||
path: string;
|
||||
rawJson?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function setNestedProperty(
|
||||
@@ -277,8 +293,11 @@ export class LoadedSettings {
|
||||
this.system = system;
|
||||
this.systemDefaults = systemDefaults;
|
||||
this.user = user;
|
||||
this.workspace = workspace;
|
||||
this._workspaceFile = workspace;
|
||||
this.isTrusted = isTrusted;
|
||||
this.workspace = isTrusted
|
||||
? workspace
|
||||
: this.createEmptyWorkspace(workspace);
|
||||
this.errors = errors;
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
@@ -286,10 +305,11 @@ export class LoadedSettings {
|
||||
readonly system: SettingsFile;
|
||||
readonly systemDefaults: SettingsFile;
|
||||
readonly user: SettingsFile;
|
||||
readonly workspace: SettingsFile;
|
||||
readonly isTrusted: boolean;
|
||||
workspace: SettingsFile;
|
||||
isTrusted: boolean;
|
||||
readonly errors: SettingsError[];
|
||||
|
||||
private _workspaceFile: SettingsFile;
|
||||
private _merged: MergedSettings;
|
||||
private _remoteAdminSettings: Partial<Settings> | undefined;
|
||||
|
||||
@@ -297,6 +317,26 @@ export class LoadedSettings {
|
||||
return this._merged;
|
||||
}
|
||||
|
||||
setTrusted(isTrusted: boolean): void {
|
||||
if (this.isTrusted === isTrusted) {
|
||||
return;
|
||||
}
|
||||
this.isTrusted = isTrusted;
|
||||
this.workspace = isTrusted
|
||||
? this._workspaceFile
|
||||
: this.createEmptyWorkspace(this._workspaceFile);
|
||||
this._merged = this.computeMergedSettings();
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
|
||||
private createEmptyWorkspace(workspace: SettingsFile): SettingsFile {
|
||||
return {
|
||||
...workspace,
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
};
|
||||
}
|
||||
|
||||
private computeMergedSettings(): MergedSettings {
|
||||
const merged = mergeSettings(
|
||||
this.system.settings,
|
||||
@@ -339,23 +379,39 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private isPersistable(settingsFile: SettingsFile): boolean {
|
||||
return !settingsFile.readOnly;
|
||||
}
|
||||
|
||||
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
setNestedProperty(settingsFile.settings, key, value);
|
||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||
|
||||
// Clone value to prevent reference sharing
|
||||
const valueToSet =
|
||||
typeof value === 'object' && value !== null
|
||||
? structuredClone(value)
|
||||
: value;
|
||||
|
||||
setNestedProperty(settingsFile.settings, key, valueToSet);
|
||||
|
||||
if (this.isPersistable(settingsFile)) {
|
||||
// Use a fresh clone for originalSettings to ensure total independence
|
||||
setNestedProperty(
|
||||
settingsFile.originalSettings,
|
||||
key,
|
||||
structuredClone(valueToSet),
|
||||
);
|
||||
saveSettings(settingsFile);
|
||||
}
|
||||
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
|
||||
setRemoteAdminSettings(remoteSettings: FetchAdminControlsResponse): void {
|
||||
setRemoteAdminSettings(remoteSettings: AdminControlsSettings): void {
|
||||
const admin: Settings['admin'] = {};
|
||||
const {
|
||||
secureModeEnabled,
|
||||
strictModeDisabled,
|
||||
mcpSetting,
|
||||
cliFeatureSetting,
|
||||
} = remoteSettings;
|
||||
const { strictModeDisabled, mcpSetting, cliFeatureSetting } =
|
||||
remoteSettings;
|
||||
|
||||
if (Object.keys(remoteSettings).length === 0) {
|
||||
this._remoteAdminSettings = { admin };
|
||||
@@ -363,19 +419,16 @@ export class LoadedSettings {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strictModeDisabled !== undefined) {
|
||||
admin.secureModeEnabled = !strictModeDisabled;
|
||||
} else if (secureModeEnabled !== undefined) {
|
||||
admin.secureModeEnabled = secureModeEnabled;
|
||||
} else {
|
||||
admin.secureModeEnabled = true;
|
||||
}
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
|
||||
admin.secureModeEnabled = !strictModeDisabled;
|
||||
admin.mcp = {
|
||||
enabled: mcpSetting?.mcpEnabled,
|
||||
config: mcpSetting?.mcpConfig?.mcpServers,
|
||||
};
|
||||
admin.extensions = {
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled,
|
||||
};
|
||||
admin.skills = {
|
||||
enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled ?? false,
|
||||
enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled,
|
||||
};
|
||||
|
||||
this._remoteAdminSettings = { admin };
|
||||
@@ -412,26 +465,30 @@ function findEnvFile(startDir: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function setUpCloudShellEnvironment(envFilePath: string | null): void {
|
||||
export function setUpCloudShellEnvironment(
|
||||
envFilePath: string | null,
|
||||
isTrusted: boolean,
|
||||
isSandboxed: boolean,
|
||||
): void {
|
||||
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
|
||||
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
|
||||
// set by the user using "gcloud config set project" we do not want to
|
||||
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
|
||||
// one of the .env files, we set the Cloud Shell-specific default here.
|
||||
let value = 'cloudshell-gca';
|
||||
|
||||
if (envFilePath && fs.existsSync(envFilePath)) {
|
||||
const envFileContent = fs.readFileSync(envFilePath);
|
||||
const parsedEnv = dotenv.parse(envFileContent);
|
||||
if (parsedEnv['GOOGLE_CLOUD_PROJECT']) {
|
||||
// .env file takes precedence in Cloud Shell
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT'];
|
||||
} else {
|
||||
// If not in .env, set to default and override global
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
|
||||
value = parsedEnv['GOOGLE_CLOUD_PROJECT'];
|
||||
if (!isTrusted && isSandboxed) {
|
||||
value = sanitizeEnvVar(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no .env file, set to default and override global
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
|
||||
}
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = value;
|
||||
}
|
||||
|
||||
export function loadEnvironment(
|
||||
@@ -442,13 +499,29 @@ export function loadEnvironment(
|
||||
const envFilePath = findEnvFile(workspaceDir);
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
|
||||
if (trustResult.isTrusted !== true) {
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
// Check settings OR check process.argv directly since this might be called
|
||||
// before arguments are fully parsed. This is a best-effort sniffing approach
|
||||
// that happens early in the CLI lifecycle. It is designed to detect the
|
||||
// sandbox flag before the full command-line parser is initialized to ensure
|
||||
// security constraints are applied when loading environment variables.
|
||||
const args = process.argv.slice(2);
|
||||
const doubleDashIndex = args.indexOf('--');
|
||||
const relevantArgs =
|
||||
doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex);
|
||||
|
||||
const isSandboxed =
|
||||
!!settings.tools?.sandbox ||
|
||||
relevantArgs.includes('-s') ||
|
||||
relevantArgs.includes('--sandbox');
|
||||
|
||||
if (trustResult.isTrusted !== true && !isSandboxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||
setUpCloudShellEnvironment(envFilePath);
|
||||
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
||||
}
|
||||
|
||||
if (envFilePath) {
|
||||
@@ -464,6 +537,16 @@ export function loadEnvironment(
|
||||
|
||||
for (const key in parsedEnv) {
|
||||
if (Object.hasOwn(parsedEnv, key)) {
|
||||
let value = parsedEnv[key];
|
||||
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
|
||||
if (!isTrusted && isSandboxed) {
|
||||
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
// Sanitize the value for untrusted sources
|
||||
value = sanitizeEnvVar(value);
|
||||
}
|
||||
|
||||
// If it's a project .env file, skip loading excluded variables.
|
||||
if (isProjectEnvFile && excludedVars.includes(key)) {
|
||||
continue;
|
||||
@@ -471,7 +554,7 @@ export function loadEnvironment(
|
||||
|
||||
// Load variable only if it's not already set in the environment.
|
||||
if (!Object.hasOwn(process.env, key)) {
|
||||
process.env[key] = parsedEnv[key];
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,9 +685,10 @@ export function loadSettings(
|
||||
// For the initial trust check, we can only use user and system settings.
|
||||
const initialTrustCheckSettings = customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{},
|
||||
systemSettings,
|
||||
getDefaultsFromSchema(),
|
||||
systemDefaultSettings,
|
||||
userSettings,
|
||||
systemSettings,
|
||||
);
|
||||
const isTrusted =
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir)
|
||||
@@ -640,24 +724,28 @@ export function loadSettings(
|
||||
settings: systemSettings,
|
||||
originalSettings: systemOriginalSettings,
|
||||
rawJson: systemResult.rawJson,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
path: systemDefaultsPath,
|
||||
settings: systemDefaultSettings,
|
||||
originalSettings: systemDefaultsOriginalSettings,
|
||||
rawJson: systemDefaultsResult.rawJson,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
path: USER_SETTINGS_PATH,
|
||||
settings: userSettings,
|
||||
originalSettings: userOriginalSettings,
|
||||
rawJson: userResult.rawJson,
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
path: workspaceSettingsPath,
|
||||
settings: workspaceSettings,
|
||||
originalSettings: workspaceOriginalSettings,
|
||||
rawJson: workspaceResult.rawJson,
|
||||
readOnly: false,
|
||||
},
|
||||
isTrusted,
|
||||
settingsErrors,
|
||||
@@ -682,153 +770,158 @@ export function migrateDeprecatedSettings(
|
||||
removeDeprecated = false,
|
||||
): boolean {
|
||||
let anyModified = false;
|
||||
const processScope = (scope: LoadableSettingScope) => {
|
||||
const settings = loadedSettings.forScope(scope).settings;
|
||||
const systemWarnings: Map<LoadableSettingScope, string[]> = new Map();
|
||||
|
||||
// Migrate inverted boolean settings (disableX -> enableX)
|
||||
// These settings were renamed and their boolean logic inverted
|
||||
/**
|
||||
* Helper to migrate a boolean setting and track it if it's deprecated.
|
||||
*/
|
||||
const migrateBoolean = (
|
||||
settings: Record<string, unknown>,
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
prefix: string,
|
||||
foundDeprecated?: string[],
|
||||
): boolean => {
|
||||
let modified = false;
|
||||
const oldValue = settings[oldKey];
|
||||
const newValue = settings[newKey];
|
||||
|
||||
if (typeof oldValue === 'boolean') {
|
||||
if (foundDeprecated) {
|
||||
foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey);
|
||||
}
|
||||
if (typeof newValue === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete settings[oldKey];
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
// Only old exists, migrate to new (inverted)
|
||||
settings[newKey] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete settings[oldKey];
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
};
|
||||
|
||||
const processScope = (scope: LoadableSettingScope) => {
|
||||
const settingsFile = loadedSettings.forScope(scope);
|
||||
const settings = settingsFile.settings;
|
||||
const foundDeprecated: string[] = [];
|
||||
|
||||
// Migrate general settings
|
||||
const generalSettings = settings.general as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
const contextSettings = settings.context as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
// Migrate general settings (disableAutoUpdate, disableUpdateNag)
|
||||
if (generalSettings) {
|
||||
const newGeneral: Record<string, unknown> = { ...generalSettings };
|
||||
const newGeneral = { ...generalSettings };
|
||||
let modified = false;
|
||||
|
||||
if (typeof newGeneral['disableAutoUpdate'] === 'boolean') {
|
||||
if (typeof newGeneral['enableAutoUpdate'] === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableAutoUpdate'];
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = newGeneral['disableAutoUpdate'];
|
||||
newGeneral['enableAutoUpdate'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableAutoUpdate'];
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof newGeneral['disableUpdateNag'] === 'boolean') {
|
||||
if (typeof newGeneral['enableAutoUpdateNotification'] === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableUpdateNag'];
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = newGeneral['disableUpdateNag'];
|
||||
newGeneral['enableAutoUpdateNotification'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newGeneral['disableUpdateNag'];
|
||||
}
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
modified =
|
||||
migrateBoolean(
|
||||
newGeneral,
|
||||
'disableAutoUpdate',
|
||||
'enableAutoUpdate',
|
||||
'general',
|
||||
foundDeprecated,
|
||||
) || modified;
|
||||
modified =
|
||||
migrateBoolean(
|
||||
newGeneral,
|
||||
'disableUpdateNag',
|
||||
'enableAutoUpdateNotification',
|
||||
'general',
|
||||
foundDeprecated,
|
||||
) || modified;
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
anyModified = true;
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate ui settings
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
if (uiSettings) {
|
||||
const newUi: Record<string, unknown> = { ...uiSettings };
|
||||
let modified = false;
|
||||
|
||||
// Migrate ui.accessibility.disableLoadingPhrases -> ui.accessibility.enableLoadingPhrases
|
||||
const newUi = { ...uiSettings };
|
||||
const accessibilitySettings = newUi['accessibility'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (
|
||||
accessibilitySettings &&
|
||||
typeof accessibilitySettings['disableLoadingPhrases'] === 'boolean'
|
||||
) {
|
||||
const newAccessibility: Record<string, unknown> = {
|
||||
...accessibilitySettings,
|
||||
};
|
||||
if (
|
||||
typeof accessibilitySettings['enableLoadingPhrases'] === 'boolean'
|
||||
) {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newAccessibility['disableLoadingPhrases'];
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = accessibilitySettings['disableLoadingPhrases'];
|
||||
newAccessibility['enableLoadingPhrases'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newAccessibility['disableLoadingPhrases'];
|
||||
}
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
anyModified = true;
|
||||
if (accessibilitySettings) {
|
||||
const newAccessibility = { ...accessibilitySettings };
|
||||
if (
|
||||
migrateBoolean(
|
||||
newAccessibility,
|
||||
'disableLoadingPhrases',
|
||||
'enableLoadingPhrases',
|
||||
'ui.accessibility',
|
||||
foundDeprecated,
|
||||
)
|
||||
) {
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate context settings
|
||||
const contextSettings = settings.context as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (contextSettings) {
|
||||
const newContext: Record<string, unknown> = { ...contextSettings };
|
||||
let modified = false;
|
||||
|
||||
// Migrate context.fileFiltering.disableFuzzySearch -> context.fileFiltering.enableFuzzySearch
|
||||
const newContext = { ...contextSettings };
|
||||
const fileFilteringSettings = newContext['fileFiltering'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (
|
||||
fileFilteringSettings &&
|
||||
typeof fileFilteringSettings['disableFuzzySearch'] === 'boolean'
|
||||
) {
|
||||
const newFileFiltering: Record<string, unknown> = {
|
||||
...fileFilteringSettings,
|
||||
};
|
||||
if (typeof fileFilteringSettings['enableFuzzySearch'] === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
delete newFileFiltering['disableFuzzySearch'];
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
modified = true;
|
||||
}
|
||||
} else {
|
||||
const oldValue = fileFilteringSettings['disableFuzzySearch'];
|
||||
newFileFiltering['enableFuzzySearch'] = !oldValue;
|
||||
if (removeDeprecated) {
|
||||
delete newFileFiltering['disableFuzzySearch'];
|
||||
}
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
anyModified = true;
|
||||
if (fileFilteringSettings) {
|
||||
const newFileFiltering = { ...fileFilteringSettings };
|
||||
if (
|
||||
migrateBoolean(
|
||||
newFileFiltering,
|
||||
'disableFuzzySearch',
|
||||
'enableFuzzySearch',
|
||||
'context.fileFiltering',
|
||||
foundDeprecated,
|
||||
)
|
||||
) {
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
anyModified ||= migrateExperimentalSettings(
|
||||
const experimentalModified = migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
foundDeprecated,
|
||||
);
|
||||
|
||||
if (experimentalModified) {
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsFile.readOnly && foundDeprecated.length > 0) {
|
||||
systemWarnings.set(scope, foundDeprecated);
|
||||
}
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
@@ -836,6 +929,19 @@ export function migrateDeprecatedSettings(
|
||||
processScope(SettingScope.System);
|
||||
processScope(SettingScope.SystemDefaults);
|
||||
|
||||
if (systemWarnings.size > 0) {
|
||||
for (const [scope, flags] of systemWarnings) {
|
||||
const scopeName =
|
||||
scope === SettingScope.SystemDefaults
|
||||
? 'system default'
|
||||
: scope.toLowerCase();
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return anyModified;
|
||||
}
|
||||
|
||||
@@ -883,10 +989,12 @@ function migrateExperimentalSettings(
|
||||
loadedSettings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
removeDeprecated: boolean,
|
||||
foundDeprecated?: string[],
|
||||
): boolean {
|
||||
const experimentalSettings = settings.experimental as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (experimentalSettings) {
|
||||
const agentsSettings = {
|
||||
...(settings.agents as Record<string, unknown> | undefined),
|
||||
@@ -896,11 +1004,20 @@ function migrateExperimentalSettings(
|
||||
};
|
||||
let modified = false;
|
||||
|
||||
const migrateExperimental = (
|
||||
oldKey: string,
|
||||
migrateFn: (oldValue: Record<string, unknown>) => void,
|
||||
) => {
|
||||
const old = experimentalSettings[oldKey];
|
||||
if (old) {
|
||||
foundDeprecated?.push(`experimental.${oldKey}`);
|
||||
migrateFn(old as Record<string, unknown>);
|
||||
modified = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
|
||||
if (experimentalSettings['codebaseInvestigatorSettings']) {
|
||||
const old = experimentalSettings[
|
||||
'codebaseInvestigatorSettings'
|
||||
] as Record<string, unknown>;
|
||||
migrateExperimental('codebaseInvestigatorSettings', (old) => {
|
||||
const override = {
|
||||
...(agentsOverrides['codebase_investigator'] as
|
||||
| Record<string, unknown>
|
||||
@@ -942,22 +1059,16 @@ function migrateExperimentalSettings(
|
||||
}
|
||||
|
||||
agentsOverrides['codebase_investigator'] = override;
|
||||
modified = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
|
||||
if (experimentalSettings['cliHelpAgentSettings']) {
|
||||
const old = experimentalSettings['cliHelpAgentSettings'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
migrateExperimental('cliHelpAgentSettings', (old) => {
|
||||
const override = {
|
||||
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
agentsOverrides['cli_help'] = override;
|
||||
modified = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (modified) {
|
||||
agentsSettings['overrides'] = agentsOverrides;
|
||||
|
||||
@@ -294,7 +294,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.default,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.showInDialog,
|
||||
@@ -328,30 +328,6 @@ describe('SettingsSchema', () => {
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
|
||||
it('should have previewFeatures setting in schema', () => {
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures,
|
||||
).toBeDefined();
|
||||
expect(getSettingsSchema().general.properties.previewFeatures.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.category,
|
||||
).toBe('General');
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.default,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.requiresRestart,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.description,
|
||||
).toBe('Enable preview features (e.g., preview models).');
|
||||
});
|
||||
|
||||
it('should have enableAgents setting in schema', () => {
|
||||
const setting = getSettingsSchema().experimental.properties.enableAgents;
|
||||
expect(setting).toBeDefined();
|
||||
@@ -389,20 +365,6 @@ describe('SettingsSchema', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should have enableEventDrivenScheduler setting in schema', () => {
|
||||
const setting =
|
||||
getSettingsSchema().experimental.properties.enableEventDrivenScheduler;
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
'Enables event-driven scheduler within the CLI session.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should have hooksConfig.notifications setting in schema', () => {
|
||||
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
|
||||
expect(setting).toBeDefined();
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
type MCPServerConfig,
|
||||
@@ -162,15 +161,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'General application settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
previewFeatures: {
|
||||
type: 'boolean',
|
||||
label: 'Preview Features (e.g., models)',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable preview features (e.g., preview models).',
|
||||
showInDialog: true,
|
||||
},
|
||||
preferredEditor: {
|
||||
type: 'string',
|
||||
label: 'Preferred Editor',
|
||||
@@ -1158,15 +1148,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableToolOutputTruncation: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Truncation',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable truncation of large tool outputs.',
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Threshold',
|
||||
@@ -1174,16 +1155,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
description:
|
||||
'Truncate tool output if it is larger than this many characters. Set to -1 to disable.',
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputLines: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Lines',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
description: 'The number of lines to keep when truncating tool output.',
|
||||
'Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.',
|
||||
showInDialog: true,
|
||||
},
|
||||
disableLLMCorrection: {
|
||||
@@ -1312,7 +1284,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Folder Trust',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Setting to track whether Folder trust is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
@@ -1462,6 +1434,58 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
toolOutputMasking: {
|
||||
type: 'object',
|
||||
label: 'Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: true,
|
||||
default: {},
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
showInDialog: false,
|
||||
},
|
||||
toolProtectionThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Protection Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 50000,
|
||||
description:
|
||||
'Minimum number of tokens to protect from masking (most recent tool outputs).',
|
||||
showInDialog: false,
|
||||
},
|
||||
minPrunableTokensThreshold: {
|
||||
type: 'number',
|
||||
label: 'Min Prunable Tokens Threshold',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 30000,
|
||||
description:
|
||||
'Minimum prunable tokens required to trigger a masking pass.',
|
||||
showInDialog: false,
|
||||
},
|
||||
protectLatestTurn: {
|
||||
type: 'boolean',
|
||||
label: 'Protect Latest Turn',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Ensures the absolute latest turn is never masked, regardless of token count.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
enableAgents: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Agents',
|
||||
@@ -1486,17 +1510,8 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Extension Configuration',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable requesting and fetching of extension settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableEventDrivenScheduler: {
|
||||
type: 'boolean',
|
||||
label: 'Event Driven Scheduler',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enables event-driven scheduler within the CLI session.',
|
||||
description: 'Enable requesting and fetching of extension settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionReloading: {
|
||||
@@ -1867,6 +1882,20 @@ const SETTINGS_SCHEMA = {
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
label: 'MCP Config',
|
||||
category: 'Admin',
|
||||
requiresRestart: false,
|
||||
default: {} as Record<string, MCPServerConfig>,
|
||||
description: 'Admin-configured MCP servers.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
ref: 'MCPServerConfig',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
|
||||
@@ -134,7 +134,6 @@ describe('Settings Repro', () => {
|
||||
enablePromptCompletion: false,
|
||||
preferredEditor: 'vim',
|
||||
vimMode: false,
|
||||
previewFeatures: false,
|
||||
},
|
||||
security: {
|
||||
auth: {
|
||||
@@ -150,7 +149,6 @@ describe('Settings Repro', () => {
|
||||
showColor: true,
|
||||
enableInteractiveShell: true,
|
||||
},
|
||||
truncateToolOutputLines: 100,
|
||||
},
|
||||
experimental: {
|
||||
useModelRouter: false,
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
|
||||
import * as osActual from 'node:os';
|
||||
import { FatalConfigError, ideContextStore } from '@google/gemini-cli-core';
|
||||
import {
|
||||
FatalConfigError,
|
||||
ideContextStore,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
@@ -26,6 +30,9 @@ import {
|
||||
isWorkspaceTrusted,
|
||||
resetTrustedFoldersForTesting,
|
||||
} from './trustedFolders.js';
|
||||
import { loadEnvironment, getSettingsSchema } from './settings.js';
|
||||
import { createMockSettings } from '../test-utils/settings.js';
|
||||
import { validateAuthMethod } from './auth.js';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
@@ -53,6 +60,7 @@ vi.mock('fs', async (importOriginal) => {
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: vi.fn().mockImplementation((p) => p),
|
||||
};
|
||||
});
|
||||
vi.mock('strip-json-comments', () => ({
|
||||
@@ -60,22 +68,23 @@ vi.mock('strip-json-comments', () => ({
|
||||
}));
|
||||
|
||||
describe('Trusted Folders Loading', () => {
|
||||
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
|
||||
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
|
||||
let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.resetAllMocks();
|
||||
mockFsExistsSync = vi.mocked(fs.existsSync);
|
||||
mockStripJsonComments = vi.mocked(stripJsonComments);
|
||||
mockFsWriteFileSync = vi.mocked(fs.writeFileSync);
|
||||
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
|
||||
(mockStripJsonComments as unknown as Mock).mockImplementation(
|
||||
(jsonString: string) => jsonString,
|
||||
);
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false);
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -90,13 +99,16 @@ describe('Trusted Folders Loading', () => {
|
||||
|
||||
describe('isPathTrusted', () => {
|
||||
function setup({ config = {} as Record<string, TrustLevel> } = {}) {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath())
|
||||
return JSON.stringify(config);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) return JSON.stringify(config);
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
@@ -124,26 +136,62 @@ describe('Trusted Folders Loading', () => {
|
||||
expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true);
|
||||
|
||||
// No explicit rule covers this file
|
||||
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(false);
|
||||
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(false);
|
||||
expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('prioritizes the longest matching path (precedence)', () => {
|
||||
const { folders } = setup({
|
||||
config: {
|
||||
'/a': TrustLevel.TRUST_FOLDER,
|
||||
'/a/b': TrustLevel.DO_NOT_TRUST,
|
||||
'/a/b/c': TrustLevel.TRUST_FOLDER,
|
||||
'/parent/trustme': TrustLevel.TRUST_PARENT, // effective path is /parent
|
||||
'/parent/trustme/butnotthis': TrustLevel.DO_NOT_TRUST,
|
||||
},
|
||||
});
|
||||
|
||||
// /a/b/c/d matches /a (len 2), /a/b (len 4), /a/b/c (len 6).
|
||||
// /a/b/c wins (TRUST_FOLDER).
|
||||
expect(folders.isPathTrusted('/a/b/c/d')).toBe(true);
|
||||
|
||||
// /a/b/x matches /a (len 2), /a/b (len 4).
|
||||
// /a/b wins (DO_NOT_TRUST).
|
||||
expect(folders.isPathTrusted('/a/b/x')).toBe(false);
|
||||
|
||||
// /a/x matches /a (len 2).
|
||||
// /a wins (TRUST_FOLDER).
|
||||
expect(folders.isPathTrusted('/a/x')).toBe(true);
|
||||
|
||||
// Overlap with TRUST_PARENT
|
||||
// /parent/trustme/butnotthis/file matches:
|
||||
// - /parent/trustme (len 15, TRUST_PARENT -> effective /parent)
|
||||
// - /parent/trustme/butnotthis (len 26, DO_NOT_TRUST)
|
||||
// /parent/trustme/butnotthis wins.
|
||||
expect(folders.isPathTrusted('/parent/trustme/butnotthis/file')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// /parent/other matches /parent/trustme (len 15, effective /parent)
|
||||
expect(folders.isPathTrusted('/parent/other')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should load user rules if only user file exists', () => {
|
||||
const userPath = getTrustedFoldersPath();
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === userPath,
|
||||
);
|
||||
const userContent = {
|
||||
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === userPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === userPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
@@ -154,11 +202,15 @@ describe('Trusted Folders Loading', () => {
|
||||
|
||||
it('should handle JSON parsing errors gracefully', () => {
|
||||
const userPath = getTrustedFoldersPath();
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === userPath) return 'invalid json';
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === userPath,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === userPath) return 'invalid json';
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([]);
|
||||
@@ -171,14 +223,18 @@ describe('Trusted Folders Loading', () => {
|
||||
const customPath = '/custom/path/to/trusted_folders.json';
|
||||
process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath;
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === customPath);
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p: fs.PathLike) => p.toString() === customPath,
|
||||
);
|
||||
const userContent = {
|
||||
'/user/folder/from/env': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||
if (p === customPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === customPath) return JSON.stringify(userContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
@@ -221,14 +277,16 @@ describe('isWorkspaceTrusted', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
(p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -241,12 +299,14 @@ describe('isWorkspaceTrusted', () => {
|
||||
it('should throw a fatal error if the config is malformed', () => {
|
||||
mockCwd = '/home/user/projectA';
|
||||
// This mock needs to be specific to this test to override the one in beforeEach
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
|
||||
/Please fix the configuration file/,
|
||||
@@ -255,12 +315,14 @@ describe('isWorkspaceTrusted', () => {
|
||||
|
||||
it('should throw a fatal error if the config is not a JSON object', () => {
|
||||
mockCwd = '/home/user/projectA';
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return 'null';
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return 'null';
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
|
||||
/not a valid JSON object/,
|
||||
@@ -303,10 +365,10 @@ describe('isWorkspaceTrusted', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for a child of an untrusted folder', () => {
|
||||
it('should return false for a child of an untrusted folder', () => {
|
||||
mockCwd = '/home/user/untrusted/src';
|
||||
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined when no rules match', () => {
|
||||
@@ -316,12 +378,12 @@ describe('isWorkspaceTrusted', () => {
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prioritize trust over distrust', () => {
|
||||
it('should prioritize specific distrust over parent trust', () => {
|
||||
mockCwd = '/home/user/projectA/untrusted';
|
||||
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
|
||||
mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
@@ -351,6 +413,19 @@ describe('isWorkspaceTrusted', () => {
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted with IDE override', () => {
|
||||
const mockCwd = '/home/user/projectA';
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString().endsWith('trustedFolders.json') ? false : true,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ideContextStore.clear();
|
||||
@@ -390,10 +465,15 @@ describe('isWorkspaceTrusted with IDE override', () => {
|
||||
});
|
||||
|
||||
it('should fall back to config when ideTrust is undefined', () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }),
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p) =>
|
||||
p === getTrustedFoldersPath() || p === mockCwd ? true : false,
|
||||
);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify({ [mockCwd]: TrustLevel.TRUST_FOLDER });
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
@@ -419,8 +499,11 @@ describe('isWorkspaceTrusted with IDE override', () => {
|
||||
describe('Trusted Folders Caching', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('{}');
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -454,14 +537,20 @@ describe('invalid trust levels', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||
(p) => p === getTrustedFoldersPath(),
|
||||
(p: fs.PathLike) =>
|
||||
p.toString() === getTrustedFoldersPath() || p.toString() === mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -495,3 +584,235 @@ describe('invalid trust levels', () => {
|
||||
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verification: Auth and Trust Interaction', () => {
|
||||
let mockCwd: string;
|
||||
const mockRules: Record<string, TrustLevel> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||
if (p === getTrustedFoldersPath()) {
|
||||
return JSON.stringify(mockRules);
|
||||
}
|
||||
if (p === path.resolve(mockCwd, '.env')) {
|
||||
return 'GEMINI_API_KEY=shhh-secret';
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||
(p) =>
|
||||
p === getTrustedFoldersPath() || p === path.resolve(mockCwd, '.env'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.keys(mockRules).forEach((key) => delete mockRules[key]);
|
||||
});
|
||||
|
||||
it('should verify loadEnvironment returns early and validateAuthMethod fails when untrusted', () => {
|
||||
// 1. Mock untrusted workspace
|
||||
mockCwd = '/home/user/untrusted';
|
||||
mockRules[mockCwd] = TrustLevel.DO_NOT_TRUST;
|
||||
|
||||
// 2. Load environment (should return early)
|
||||
const settings = createMockSettings({
|
||||
security: { folderTrust: { enabled: true } },
|
||||
});
|
||||
loadEnvironment(settings.merged, mockCwd);
|
||||
|
||||
// 3. Verify env var NOT loaded
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('');
|
||||
|
||||
// 4. Verify validateAuthMethod fails
|
||||
const result = validateAuthMethod(AuthType.USE_GEMINI);
|
||||
expect(result).toContain(
|
||||
'you must specify the GEMINI_API_KEY environment variable',
|
||||
);
|
||||
});
|
||||
|
||||
it('should identify if sandbox flag is available in Settings', () => {
|
||||
const schema = getSettingsSchema();
|
||||
expect(schema.tools.properties).toBeDefined();
|
||||
expect('sandbox' in schema.tools.properties).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trusted Folders realpath caching', () => {
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should only call fs.realpathSync once for the same path', () => {
|
||||
const mockPath = '/some/path';
|
||||
const mockRealPath = '/real/path';
|
||||
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
const realpathSpy = vi
|
||||
.spyOn(fs, 'realpathSync')
|
||||
.mockReturnValue(mockRealPath);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({
|
||||
[mockPath]: TrustLevel.TRUST_FOLDER,
|
||||
'/another/path': TrustLevel.TRUST_FOLDER,
|
||||
}),
|
||||
);
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
// Call isPathTrusted multiple times with the same path
|
||||
folders.isPathTrusted(mockPath);
|
||||
folders.isPathTrusted(mockPath);
|
||||
folders.isPathTrusted(mockPath);
|
||||
|
||||
// fs.realpathSync should only be called once for mockPath (at the start of isPathTrusted)
|
||||
// And once for each rule in the config (if they are different)
|
||||
|
||||
// Let's check calls for mockPath
|
||||
const mockPathCalls = realpathSpy.mock.calls.filter(
|
||||
(call) => call[0] === mockPath,
|
||||
);
|
||||
|
||||
expect(mockPathCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should cache results for rule paths in the loop', () => {
|
||||
const rulePath = '/rule/path';
|
||||
const locationPath = '/location/path';
|
||||
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
const realpathSpy = vi
|
||||
.spyOn(fs, 'realpathSync')
|
||||
.mockImplementation((p: fs.PathLike) => p.toString()); // identity for simplicity
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue(
|
||||
JSON.stringify({
|
||||
[rulePath]: TrustLevel.TRUST_FOLDER,
|
||||
}),
|
||||
);
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
|
||||
// First call
|
||||
folders.isPathTrusted(locationPath);
|
||||
const firstCallCount = realpathSpy.mock.calls.length;
|
||||
expect(firstCallCount).toBe(2); // locationPath and rulePath
|
||||
|
||||
// Second call with same location and same config
|
||||
folders.isPathTrusted(locationPath);
|
||||
const secondCallCount = realpathSpy.mock.calls.length;
|
||||
|
||||
// Should still be 2 because both were cached
|
||||
expect(secondCallCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted with Symlinks', () => {
|
||||
const mockSettings: Settings = {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
|
||||
p.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should trust a folder even if CWD is a symlink and rule is realpath', () => {
|
||||
const symlinkPath = '/var/folders/project';
|
||||
const realPath = '/private/var/folders/project';
|
||||
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(symlinkPath);
|
||||
|
||||
// Mock fs.existsSync to return true for trust config and both paths
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === getTrustedFoldersPath()) return true;
|
||||
if (pathStr === symlinkPath) return true;
|
||||
if (pathStr === realPath) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Mock realpathSync to resolve symlink to realpath
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === symlinkPath) return realPath;
|
||||
if (pathStr === realPath) return realPath;
|
||||
return pathStr;
|
||||
});
|
||||
|
||||
// Rule is saved with realpath
|
||||
const mockRules = {
|
||||
[realPath]: TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath())
|
||||
return JSON.stringify(mockRules);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Should be trusted because both resolve to the same realpath
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
|
||||
});
|
||||
|
||||
it('should trust a folder even if CWD is realpath and rule is a symlink', () => {
|
||||
const symlinkPath = '/var/folders/project';
|
||||
const realPath = '/private/var/folders/project';
|
||||
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(realPath);
|
||||
|
||||
// Mock fs.existsSync
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === getTrustedFoldersPath()) return true;
|
||||
if (pathStr === symlinkPath) return true;
|
||||
if (pathStr === realPath) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Mock realpathSync
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === symlinkPath) return realPath;
|
||||
if (pathStr === realPath) return realPath;
|
||||
return pathStr;
|
||||
});
|
||||
|
||||
// Rule is saved with symlink path
|
||||
const mockRules = {
|
||||
[symlinkPath]: TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
vi.spyOn(fs, 'readFileSync').mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p.toString() === getTrustedFoldersPath())
|
||||
return JSON.stringify(mockRules);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Should be trusted because both resolve to the same realpath
|
||||
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,9 @@ export enum TrustLevel {
|
||||
DO_NOT_TRUST = 'DO_NOT_TRUST',
|
||||
}
|
||||
|
||||
export function isTrustLevel(value: unknown): value is TrustLevel {
|
||||
export function isTrustLevel(
|
||||
value: string | number | boolean | object | null | undefined,
|
||||
): value is TrustLevel {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
Object.values(TrustLevel).includes(value as TrustLevel)
|
||||
@@ -63,6 +65,32 @@ export interface TrustResult {
|
||||
source: 'ide' | 'file' | undefined;
|
||||
}
|
||||
|
||||
const realPathCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* FOR TESTING PURPOSES ONLY.
|
||||
* Clears the real path cache.
|
||||
*/
|
||||
export function clearRealPathCacheForTesting(): void {
|
||||
realPathCache.clear();
|
||||
}
|
||||
|
||||
function getRealPath(location: string): string {
|
||||
let realPath = realPathCache.get(location);
|
||||
if (realPath !== undefined) {
|
||||
return realPath;
|
||||
}
|
||||
|
||||
try {
|
||||
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
|
||||
} catch {
|
||||
realPath = location;
|
||||
}
|
||||
|
||||
realPathCache.set(location, realPath);
|
||||
return realPath;
|
||||
}
|
||||
|
||||
export class LoadedTrustedFolders {
|
||||
constructor(
|
||||
readonly user: TrustedFoldersFile,
|
||||
@@ -88,39 +116,36 @@ export class LoadedTrustedFolders {
|
||||
config?: Record<string, TrustLevel>,
|
||||
): boolean | undefined {
|
||||
const configToUse = config ?? this.user.config;
|
||||
const trustedPaths: string[] = [];
|
||||
const untrustedPaths: string[] = [];
|
||||
|
||||
for (const rule of Object.entries(configToUse).map(
|
||||
([path, trustLevel]) => ({ path, trustLevel }),
|
||||
)) {
|
||||
switch (rule.trustLevel) {
|
||||
case TrustLevel.TRUST_FOLDER:
|
||||
trustedPaths.push(rule.path);
|
||||
break;
|
||||
case TrustLevel.TRUST_PARENT:
|
||||
trustedPaths.push(path.dirname(rule.path));
|
||||
break;
|
||||
case TrustLevel.DO_NOT_TRUST:
|
||||
untrustedPaths.push(rule.path);
|
||||
break;
|
||||
default:
|
||||
// Do nothing for unknown trust levels.
|
||||
break;
|
||||
// Resolve location to its realpath for canonical comparison
|
||||
const realLocation = getRealPath(location);
|
||||
|
||||
let longestMatchLen = -1;
|
||||
let longestMatchTrust: TrustLevel | undefined = undefined;
|
||||
|
||||
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
|
||||
const effectivePath =
|
||||
trustLevel === TrustLevel.TRUST_PARENT
|
||||
? path.dirname(rulePath)
|
||||
: rulePath;
|
||||
|
||||
// Resolve effectivePath to its realpath for canonical comparison
|
||||
const realEffectivePath = getRealPath(effectivePath);
|
||||
|
||||
if (isWithinRoot(realLocation, realEffectivePath)) {
|
||||
if (rulePath.length > longestMatchLen) {
|
||||
longestMatchLen = rulePath.length;
|
||||
longestMatchTrust = trustLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const trustedPath of trustedPaths) {
|
||||
if (isWithinRoot(location, trustedPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const untrustedPath of untrustedPaths) {
|
||||
if (path.normalize(location) === path.normalize(untrustedPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
|
||||
if (
|
||||
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
|
||||
longestMatchTrust === TrustLevel.TRUST_PARENT
|
||||
)
|
||||
return true;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -150,6 +175,7 @@ let loadedTrustedFolders: LoadedTrustedFolders | undefined;
|
||||
*/
|
||||
export function resetTrustedFoldersForTesting(): void {
|
||||
loadedTrustedFolders = undefined;
|
||||
clearRealPathCacheForTesting();
|
||||
}
|
||||
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
@@ -161,11 +187,13 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
const userConfig: Record<string, TrustLevel> = {};
|
||||
|
||||
const userPath = getTrustedFoldersPath();
|
||||
// Load user trusted folders
|
||||
try {
|
||||
if (fs.existsSync(userPath)) {
|
||||
const content = fs.readFileSync(userPath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(stripJsonComments(content));
|
||||
const parsed = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
@@ -190,7 +218,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: userPath,
|
||||
@@ -222,7 +250,7 @@ export function saveTrustedFolders(
|
||||
|
||||
/** Is folder trust feature enabled per the current applied settings */
|
||||
export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? false;
|
||||
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true;
|
||||
return folderTrustSetting;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './deferred.js';
|
||||
import { ExitCodes } from '@google/gemini-cli-core';
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import { createMockSettings } from './test-utils/settings.js';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
|
||||
@@ -46,14 +46,9 @@ describe('deferred', () => {
|
||||
setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
|
||||
});
|
||||
|
||||
const createMockSettings = (adminSettings: unknown = {}): MergedSettings =>
|
||||
({
|
||||
admin: adminSettings,
|
||||
}) as unknown as MergedSettings;
|
||||
|
||||
describe('runDeferredCommand', () => {
|
||||
it('should do nothing if no deferred command is set', async () => {
|
||||
await runDeferredCommand(createMockSettings());
|
||||
await runDeferredCommand(createMockSettings().merged);
|
||||
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -66,7 +61,9 @@ describe('deferred', () => {
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: true } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { mcp: { enabled: true } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
@@ -80,7 +77,9 @@ describe('deferred', () => {
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: false } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { mcp: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
@@ -98,7 +97,9 @@ describe('deferred', () => {
|
||||
commandName: 'extensions',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ extensions: { enabled: false } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { extensions: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
@@ -116,7 +117,9 @@ describe('deferred', () => {
|
||||
commandName: 'skills',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ skills: { enabled: false } });
|
||||
const settings = createMockSettings({
|
||||
merged: { admin: { skills: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
@@ -135,7 +138,7 @@ describe('deferred', () => {
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({}); // No admin settings
|
||||
const settings = createMockSettings({}).merged; // No admin settings
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
@@ -163,8 +166,16 @@ describe('deferred', () => {
|
||||
expect(originalHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Now manually run it to verify it captured correctly
|
||||
await runDeferredCommand(createMockSettings());
|
||||
expect(originalHandler).toHaveBeenCalledWith(argv);
|
||||
await runDeferredCommand(createMockSettings().merged);
|
||||
expect(originalHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
admin: expect.objectContaining({
|
||||
extensions: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
|
||||
@@ -181,7 +192,9 @@ describe('deferred', () => {
|
||||
const deferredMcp = defer(commandModule, 'mcp');
|
||||
await deferredMcp.handler({} as ArgumentsCamelCase);
|
||||
|
||||
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
|
||||
const mcpSettings = createMockSettings({
|
||||
merged: { admin: { mcp: { enabled: false } } },
|
||||
}).merged;
|
||||
await runDeferredCommand(mcpSettings);
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
@@ -205,10 +218,14 @@ describe('deferred', () => {
|
||||
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
|
||||
// and defaulted to 'unknown' (or something else safe).
|
||||
const settings = createMockSettings({
|
||||
mcp: { enabled: false },
|
||||
extensions: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
});
|
||||
merged: {
|
||||
admin: {
|
||||
mcp: { enabled: false },
|
||||
extensions: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
},
|
||||
},
|
||||
}).merged;
|
||||
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
|
||||
@@ -63,7 +63,13 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
await deferredCommand.handler(deferredCommand.argv);
|
||||
// Inject settings into argv
|
||||
const argvWithSettings = {
|
||||
...deferredCommand.argv,
|
||||
settings,
|
||||
};
|
||||
|
||||
await deferredCommand.handler(argvWithSettings);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
+288
-527
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,7 @@ import {
|
||||
getVersion,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type FetchAdminControlsResponse,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
initializeApp,
|
||||
@@ -510,9 +510,15 @@ export async function main() {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
});
|
||||
loadConfigHandle?.end();
|
||||
|
||||
// Initialize storage immediately after loading config to ensure that
|
||||
// storage-related operations (like listing or resuming sessions) have
|
||||
// access to the project identifier.
|
||||
await config.storage.initialize();
|
||||
|
||||
adminControlsListner.setConfig(config);
|
||||
|
||||
if (config.isInteractive() && config.storage && config.getDebugMode()) {
|
||||
if (config.isInteractive() && config.getDebugMode()) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
);
|
||||
@@ -809,13 +815,13 @@ export function initializeOutputListenersAndFlush() {
|
||||
}
|
||||
|
||||
function setupAdminControlsListener() {
|
||||
let pendingSettings: FetchAdminControlsResponse | undefined;
|
||||
let pendingSettings: AdminControlsSettings | undefined;
|
||||
let config: Config | undefined;
|
||||
|
||||
const messageHandler = (msg: unknown) => {
|
||||
const message = msg as {
|
||||
type?: string;
|
||||
settings?: FetchAdminControlsResponse;
|
||||
settings?: AdminControlsSettings;
|
||||
};
|
||||
if (message?.type === 'admin-settings' && message.settings) {
|
||||
if (config) {
|
||||
|
||||
@@ -38,6 +38,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
disableMouseEvents: vi.fn(),
|
||||
enterAlternateScreen: vi.fn(),
|
||||
disableLineWrapping: vi.fn(),
|
||||
ProjectRegistry: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
getShortId: vi.fn().mockReturnValue('project-slug'),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -73,6 +77,7 @@ vi.mock('./config/config.js', () => ({
|
||||
getSandbox: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
isInteractive: () => false,
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
} as unknown as Config),
|
||||
parseArguments: vi.fn().mockResolvedValue({}),
|
||||
isDebugMode: vi.fn(() => false),
|
||||
@@ -191,6 +196,7 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getEnableHooks: vi.fn(() => false),
|
||||
getHookSystem: () => undefined,
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
|
||||
@@ -267,8 +267,8 @@ describe('runNonInteractive', () => {
|
||||
// so we no longer expect shutdownTelemetry to be called directly here
|
||||
});
|
||||
|
||||
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '/tmp/test.jsonl');
|
||||
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl');
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
@@ -290,8 +290,8 @@ describe('runNonInteractive', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is not set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '');
|
||||
it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '');
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function runNonInteractive({
|
||||
},
|
||||
});
|
||||
|
||||
if (config.storage && process.env['GEMINI_CLI_ACTIVITY_LOG_FILE']) {
|
||||
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
);
|
||||
|
||||
@@ -85,6 +85,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
extensionsCommand: () => ({}),
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
|
||||
vi.mock('../ui/commands/shortcutsCommand.js', () => ({
|
||||
shortcutsCommand: {},
|
||||
}));
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
|
||||
vi.mock('../ui/commands/modelCommand.js', () => ({
|
||||
modelCommand: { name: 'model' },
|
||||
@@ -98,6 +101,17 @@ vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
|
||||
vi.mock('../ui/commands/skillsCommand.js', () => ({
|
||||
skillsCommand: { name: 'skills' },
|
||||
}));
|
||||
vi.mock('../ui/commands/planCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
planCommand: {
|
||||
name: 'plan',
|
||||
description: 'Plan command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/mcpCommand.js', () => ({
|
||||
mcpCommand: {
|
||||
name: 'mcp',
|
||||
@@ -115,6 +129,7 @@ describe('BuiltinCommandLoader', () => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getEnableExtensionReloading: () => false,
|
||||
getEnableHooks: () => false,
|
||||
getEnableHooksUI: () => false,
|
||||
@@ -216,6 +231,22 @@ describe('BuiltinCommandLoader', () => {
|
||||
expect(agentsCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include plan command when plan mode is enabled', async () => {
|
||||
(mockConfig.isPlanEnabled as Mock).mockReturnValue(true);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const planCmd = commands.find((c) => c.name === 'plan');
|
||||
expect(planCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude plan command when plan mode is disabled', async () => {
|
||||
(mockConfig.isPlanEnabled as Mock).mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const planCmd = commands.find((c) => c.name === 'plan');
|
||||
expect(planCmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exclude agents command when agents are disabled', async () => {
|
||||
mockConfig.isAgentsEnabled = vi.fn().mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
@@ -256,6 +287,7 @@ describe('BuiltinCommandLoader profile', () => {
|
||||
vi.resetModules();
|
||||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: () => false,
|
||||
getEnableExtensionReloading: () => false,
|
||||
getEnableHooks: () => false,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
|
||||
import { rewindCommand } from '../ui/commands/rewindCommand.js';
|
||||
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
@@ -40,8 +41,9 @@ import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { planCommand } from '../ui/commands/planCommand.js';
|
||||
import { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { profileCommand } from '../ui/commands/profileCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
@@ -115,6 +117,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
]
|
||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||
helpCommand,
|
||||
shortcutsCommand,
|
||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||
rewindCommand,
|
||||
await ideCommand(),
|
||||
@@ -142,8 +145,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
privacyCommand,
|
||||
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||
policiesCommand,
|
||||
privacyCommand,
|
||||
...(isDevelopment ? [profileCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
|
||||
@@ -60,6 +60,7 @@ export const createMockCommandContext = (
|
||||
setPendingItem: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleShortcutsHelp: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings, Settings } from '../config/settings.js';
|
||||
import { createTestMergedSettings } from '../config/settings.js';
|
||||
|
||||
/**
|
||||
* Creates a mocked Config object with default values and allows overrides.
|
||||
*/
|
||||
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
getSandbox: vi.fn(() => undefined),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
isInteractive: vi.fn(() => false),
|
||||
setTerminalBackground: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
||||
getRemoteAdminSettings: vi.fn(() => undefined),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getPolicyEngine: vi.fn(() => ({})),
|
||||
getMessageBus: vi.fn(() => ({ subscribe: vi.fn() })),
|
||||
getHookSystem: vi.fn(() => ({
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
setSessionId: vi.fn(),
|
||||
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
|
||||
getExperimentalZedIntegration: vi.fn(() => false),
|
||||
isBrowserLaunchSuppressed: vi.fn(() => false),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
isYoloModeDisabled: vi.fn(() => false),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
isEventDrivenSchedulerEnabled: vi.fn(() => false),
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getAllowedTools: vi.fn(() => []),
|
||||
getApprovalMode: vi.fn(() => 'default'),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getDeferredCommand: vi.fn(() => undefined),
|
||||
getFileSystemService: vi.fn(() => ({})),
|
||||
clientVersion: '1.0.0',
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTools: vi.fn().mockReturnValue([]),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getAgentRegistry: vi.fn().mockReturnValue({}),
|
||||
getPromptRegistry: vi.fn().mockReturnValue({}),
|
||||
getResourceRegistry: vi.fn().mockReturnValue({}),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
isAdminEnabled: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
getFileService: vi.fn().mockReturnValue({}),
|
||||
getGitService: vi.fn().mockResolvedValue({}),
|
||||
getUserMemory: vi.fn().mockReturnValue(''),
|
||||
getGeminiMdFilePaths: vi.fn().mockReturnValue([]),
|
||||
getShowMemoryUsage: vi.fn().mockReturnValue(false),
|
||||
getAccessibility: vi.fn().mockReturnValue({}),
|
||||
getTelemetryEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''),
|
||||
getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'),
|
||||
getTelemetryTarget: vi.fn().mockReturnValue(''),
|
||||
getTelemetryOutfile: vi.fn().mockReturnValue(undefined),
|
||||
getTelemetryUseCollector: vi.fn().mockReturnValue(false),
|
||||
getTelemetryUseCliAuth: vi.fn().mockReturnValue(false),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
updateSystemInstructionIfInitialized: vi.fn().mockResolvedValue(undefined),
|
||||
getModelRouterService: vi.fn().mockReturnValue({}),
|
||||
getModelAvailabilityService: vi.fn().mockReturnValue({}),
|
||||
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(true),
|
||||
getFileFilteringEnableFuzzySearch: vi.fn().mockReturnValue(true),
|
||||
getFileFilteringRespectGeminiIgnore: vi.fn().mockReturnValue(true),
|
||||
getFileFilteringOptions: vi.fn().mockReturnValue({}),
|
||||
getCustomExcludes: vi.fn().mockReturnValue([]),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getBugCommand: vi.fn().mockReturnValue(undefined),
|
||||
getExtensionManagement: vi.fn().mockReturnValue(true),
|
||||
getExtensionLoader: vi.fn().mockReturnValue({}),
|
||||
getEnabledExtensions: vi.fn().mockReturnValue([]),
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
|
||||
getDisableLLMCorrection: vi.fn().mockReturnValue(false),
|
||||
getNoBrowser: vi.fn().mockReturnValue(false),
|
||||
getAgentsSettings: vi.fn().mockReturnValue({}),
|
||||
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
|
||||
getIdeMode: vi.fn().mockReturnValue(false),
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getUserCaching: vi.fn().mockResolvedValue(false),
|
||||
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
|
||||
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
|
||||
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
|
||||
reloadSkills: vi.fn().mockResolvedValue(undefined),
|
||||
reloadAgents: vi.fn().mockResolvedValue(undefined),
|
||||
getUseRipgrep: vi.fn().mockReturnValue(false),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||
getContinueOnFailedApiCall: vi.fn().mockReturnValue(false),
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(false),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
setShellExecutionConfig: vi.fn(),
|
||||
getEnablePromptCompletion: vi.fn().mockReturnValue(false),
|
||||
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
|
||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
|
||||
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
|
||||
getNextCompressionTruncationId: vi.fn().mockReturnValue(1),
|
||||
getUseWriteTodos: vi.fn().mockReturnValue(false),
|
||||
getFileExclusions: vi.fn().mockReturnValue({}),
|
||||
getEnableHooks: vi.fn().mockReturnValue(true),
|
||||
getEnableHooksUI: vi.fn().mockReturnValue(true),
|
||||
getMcpClientManager: vi.fn().mockReturnValue({
|
||||
getMcpInstructions: vi.fn().mockReturnValue(''),
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(100),
|
||||
getExcludeTools: vi.fn().mockReturnValue(new Set()),
|
||||
getAllowedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
/**
|
||||
* Creates a mocked LoadedSettings object for tests.
|
||||
*/
|
||||
export function createMockSettings(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): LoadedSettings {
|
||||
const merged = createTestMergedSettings(
|
||||
(overrides['merged'] as Partial<Settings>) || {},
|
||||
);
|
||||
|
||||
return {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
...overrides,
|
||||
merged,
|
||||
} as unknown as LoadedSettings;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
import { act, useState } from 'react';
|
||||
import os from 'node:os';
|
||||
import { LoadedSettings, type Settings } from '../config/settings.js';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
||||
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
||||
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
||||
@@ -32,6 +32,7 @@ import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
|
||||
import { createMockSettings } from './settings.js';
|
||||
|
||||
export const persistentStateMock = new FakePersistentState();
|
||||
|
||||
@@ -135,20 +136,6 @@ export const mockSettings = new LoadedSettings(
|
||||
[],
|
||||
);
|
||||
|
||||
export const createMockSettings = (
|
||||
overrides: Partial<Settings>,
|
||||
): LoadedSettings => {
|
||||
const settings = overrides as Settings;
|
||||
return new LoadedSettings(
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings, originalSettings: settings },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
true,
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
// A minimal mock UIState to satisfy the context provider.
|
||||
// Tests that need specific UIState values should provide their own.
|
||||
const baseMockUiState = {
|
||||
@@ -204,6 +191,7 @@ const mockUIActions: UIActions = {
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
setBannerVisible: vi.fn(),
|
||||
setShortcutsHelpVisible: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import {
|
||||
LoadedSettings,
|
||||
createTestMergedSettings,
|
||||
type SettingsError,
|
||||
} from '../config/settings.js';
|
||||
|
||||
export interface MockSettingsFile {
|
||||
settings: any;
|
||||
originalSettings: any;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface CreateMockSettingsOptions {
|
||||
system?: MockSettingsFile;
|
||||
systemDefaults?: MockSettingsFile;
|
||||
user?: MockSettingsFile;
|
||||
workspace?: MockSettingsFile;
|
||||
isTrusted?: boolean;
|
||||
errors?: SettingsError[];
|
||||
merged?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock LoadedSettings object for testing.
|
||||
*
|
||||
* @param overrides - Partial settings or LoadedSettings properties to override.
|
||||
* If 'merged' is provided, it overrides the computed merged settings.
|
||||
* Any functions in overrides are assigned directly to the LoadedSettings instance.
|
||||
*/
|
||||
export const createMockSettings = (
|
||||
overrides: CreateMockSettingsOptions = {},
|
||||
): LoadedSettings => {
|
||||
const {
|
||||
system,
|
||||
systemDefaults,
|
||||
user,
|
||||
workspace,
|
||||
isTrusted,
|
||||
errors,
|
||||
merged: mergedOverride,
|
||||
...settingsOverrides
|
||||
} = overrides;
|
||||
|
||||
const loaded = new LoadedSettings(
|
||||
(system as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
(user as any) || {
|
||||
path: '',
|
||||
settings: settingsOverrides,
|
||||
originalSettings: settingsOverrides,
|
||||
},
|
||||
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
isTrusted ?? true,
|
||||
errors || [],
|
||||
);
|
||||
|
||||
if (mergedOverride) {
|
||||
// @ts-expect-error - overriding private field for testing
|
||||
loaded._merged = createTestMergedSettings(mergedOverride);
|
||||
}
|
||||
|
||||
// Assign any function overrides (e.g., vi.fn() for methods)
|
||||
for (const key in overrides) {
|
||||
if (typeof overrides[key] === 'function') {
|
||||
(loaded as any)[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
return loaded;
|
||||
};
|
||||
@@ -21,7 +21,6 @@ import { act, useContext, type ReactElement } from 'react';
|
||||
import { AppContainer } from './AppContainer.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
|
||||
import { MessageType } from './types.js';
|
||||
import {
|
||||
type Config,
|
||||
makeFakeConfig,
|
||||
@@ -29,8 +28,6 @@ import {
|
||||
type UserFeedbackPayload,
|
||||
type ResumedSessionData,
|
||||
AuthType,
|
||||
UserAccountManager,
|
||||
type ContentGeneratorConfig,
|
||||
type AgentDefinition,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
@@ -47,11 +44,6 @@ const mockIdeClient = vi.hoisted(() => ({
|
||||
getInstance: vi.fn().mockReturnValue(new Promise(() => {})),
|
||||
}));
|
||||
|
||||
// Mock UserAccountManager
|
||||
const mockUserAccountManager = vi.hoisted(() => ({
|
||||
getCachedGoogleAccount: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
// Mock stdout
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mockStdout: { write: vi.fn() },
|
||||
@@ -81,9 +73,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
})),
|
||||
enableMouseEvents: vi.fn(),
|
||||
disableMouseEvents: vi.fn(),
|
||||
UserAccountManager: vi
|
||||
.fn()
|
||||
.mockImplementation(() => mockUserAccountManager),
|
||||
FileDiscoveryService: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
})),
|
||||
@@ -428,7 +417,6 @@ describe('AppContainer State Management', () => {
|
||||
...defaultMergedSettings.ui,
|
||||
showStatusInTitle: false,
|
||||
hideWindowTitle: false,
|
||||
showUserIdentity: true,
|
||||
},
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
@@ -500,162 +488,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication Check', () => {
|
||||
it('displays correct message for LOGIN_WITH_GOOGLE auth type', async () => {
|
||||
// Explicitly mock implementation to ensure we control the instance
|
||||
(UserAccountManager as unknown as Mock).mockImplementation(
|
||||
() => mockUserAccountManager,
|
||||
);
|
||||
|
||||
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(
|
||||
'test@example.com',
|
||||
);
|
||||
const mockAddItem = vi.fn();
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem: mockAddItem,
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
|
||||
// Explicitly enable showUserIdentity
|
||||
mockSettings.merged.ui = {
|
||||
...mockSettings.merged.ui,
|
||||
showUserIdentity: true,
|
||||
};
|
||||
|
||||
// Need to ensure config.getContentGeneratorConfig() returns appropriate authType
|
||||
const authConfig = makeFakeConfig();
|
||||
// Mock getTargetDir as well since makeFakeConfig might not set it up fully for the component
|
||||
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
|
||||
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
|
||||
mockExtensionManager,
|
||||
);
|
||||
|
||||
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier');
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ config: authConfig });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserAccountManager).toHaveBeenCalled();
|
||||
expect(
|
||||
mockUserAccountManager.getCachedGoogleAccount,
|
||||
).toHaveBeenCalled();
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'Logged in with Google: test@example.com (Plan: Standard Tier)',
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
it('displays correct message for USE_GEMINI auth type', async () => {
|
||||
// Explicitly mock implementation to ensure we control the instance
|
||||
(UserAccountManager as unknown as Mock).mockImplementation(
|
||||
() => mockUserAccountManager,
|
||||
);
|
||||
|
||||
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(null);
|
||||
const mockAddItem = vi.fn();
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem: mockAddItem,
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
|
||||
const authConfig = makeFakeConfig();
|
||||
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
|
||||
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
|
||||
mockExtensionManager,
|
||||
);
|
||||
|
||||
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.USE_GEMINI,
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
vi.spyOn(authConfig, 'getUserTierName').mockReturnValue('Standard Tier');
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ config: authConfig });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('Authenticated with gemini-api-key'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not display authentication message if showUserIdentity is false', async () => {
|
||||
mockUserAccountManager.getCachedGoogleAccount.mockReturnValue(
|
||||
'test@example.com',
|
||||
);
|
||||
const mockAddItem = vi.fn();
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem: mockAddItem,
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
|
||||
mockSettings.merged.ui = {
|
||||
...mockSettings.merged.ui,
|
||||
showUserIdentity: false,
|
||||
};
|
||||
|
||||
const authConfig = makeFakeConfig();
|
||||
vi.spyOn(authConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
vi.spyOn(authConfig, 'initialize').mockResolvedValue(undefined);
|
||||
vi.spyOn(authConfig, 'getExtensionLoader').mockReturnValue(
|
||||
mockExtensionManager,
|
||||
);
|
||||
|
||||
vi.spyOn(authConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
} as unknown as ContentGeneratorConfig);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({ config: authConfig });
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
// Give it some time to potentially call addItem
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockAddItem).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context Providers', () => {
|
||||
it('provides AppContext with correct values', async () => {
|
||||
let unmount: () => void;
|
||||
@@ -2108,6 +1940,160 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Focus Handling (Tab / Shift+Tab)', () => {
|
||||
beforeEach(() => {
|
||||
// Mock activePtyId to enable focus
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should focus shell input on Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should unfocus shell input on Shift+Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
// Focus first
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Unfocus via Shift+Tab
|
||||
pressKey({ name: 'tab', shift: true });
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should auto-unfocus when activePtyId becomes null', async () => {
|
||||
// Start with active pty and focused
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 1,
|
||||
});
|
||||
|
||||
const renderResult = render(getAppContainer());
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
// Focus it
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'tab',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
} as Key);
|
||||
});
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Now mock activePtyId becoming null
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
});
|
||||
|
||||
// Rerender to trigger useEffect
|
||||
await act(async () => {
|
||||
renderResult.rerender(getAppContainer());
|
||||
});
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Initially not focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Tab
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
// Should NOT have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Background Shell Toggling (CTRL+B)', () => {
|
||||
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Initially not focused, but visible
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
// Should be unfocused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show and focus background shell on Ctrl+B if hidden', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const geminiStreamMock = {
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
};
|
||||
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Update the mock state when toggled to simulate real behavior
|
||||
mockToggleBackgroundShell.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundShellVisible = true;
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
getErrorMessage,
|
||||
getAllGeminiMdFilenames,
|
||||
AuthType,
|
||||
UserAccountManager,
|
||||
clearCachedCredentialFile,
|
||||
type ResumedSessionData,
|
||||
recordExitFail,
|
||||
@@ -142,6 +141,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { isITerm2 } from './utils/terminalUtils.js';
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
return pendingHistoryItems.some((item) => {
|
||||
@@ -191,51 +191,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const historyManager = useHistory({
|
||||
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
|
||||
});
|
||||
const { addItem } = historyManager;
|
||||
|
||||
const authCheckPerformed = useRef(false);
|
||||
useEffect(() => {
|
||||
if (authCheckPerformed.current) return;
|
||||
authCheckPerformed.current = true;
|
||||
|
||||
if (resumedSessionData || settings.merged.ui.showUserIdentity === false) {
|
||||
return;
|
||||
}
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
|
||||
// Run this asynchronously to avoid blocking the event loop.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
(async () => {
|
||||
try {
|
||||
const userAccountManager = new UserAccountManager();
|
||||
const email = userAccountManager.getCachedGoogleAccount();
|
||||
const tierName = config.getUserTierName();
|
||||
|
||||
if (authType) {
|
||||
let message =
|
||||
authType === AuthType.LOGIN_WITH_GOOGLE
|
||||
? email
|
||||
? `Logged in with Google: ${email}`
|
||||
: 'Logged in with Google'
|
||||
: `Authenticated with ${authType}`;
|
||||
if (tierName) {
|
||||
message += ` (Plan: ${tierName})`;
|
||||
}
|
||||
addItem({
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore errors during initial auth check
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
config,
|
||||
resumedSessionData,
|
||||
settings.merged.ui.showUserIdentity,
|
||||
addItem,
|
||||
]);
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
@@ -270,7 +225,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const activeHooks = useHookDisplayState();
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
|
||||
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
|
||||
isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
() => isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
);
|
||||
|
||||
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
|
||||
@@ -291,7 +246,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[defaultBannerText, warningBannerText],
|
||||
);
|
||||
|
||||
const { bannerText } = useBanner(bannerData, config);
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
// We are in the interactive CLI, update how we request consent and settings.
|
||||
@@ -570,12 +525,22 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
refreshStatic();
|
||||
}, [refreshStatic, isAlternateBuffer, app, config]);
|
||||
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
const {
|
||||
isEditorDialogOpen,
|
||||
openEditorDialog,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
} = useEditorSettings(settings, setEditorError, historyManager.addItem);
|
||||
|
||||
useEffect(() => {
|
||||
coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose);
|
||||
coreEvents.on(CoreEvent.RequestEditorSelection, openEditorDialog);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose);
|
||||
coreEvents.off(CoreEvent.RequestEditorSelection, openEditorDialog);
|
||||
};
|
||||
}, [handleEditorClose]);
|
||||
}, [handleEditorClose, openEditorDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -589,6 +554,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}
|
||||
}, [bannerVisible, bannerText, settings, config, refreshStatic]);
|
||||
|
||||
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||
useSettingsCommand();
|
||||
|
||||
const {
|
||||
isThemeDialogOpen,
|
||||
openThemeDialog,
|
||||
@@ -784,17 +752,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
onAuthError,
|
||||
]);
|
||||
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
const {
|
||||
isEditorDialogOpen,
|
||||
openEditorDialog,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
} = useEditorSettings(settings, setEditorError, historyManager.addItem);
|
||||
|
||||
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||
useSettingsCommand();
|
||||
|
||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||
useModelCommand();
|
||||
|
||||
@@ -803,6 +760,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||
|
||||
const slashCommandActions = useMemo(
|
||||
() => ({
|
||||
@@ -838,6 +796,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible),
|
||||
setText: stableSetText,
|
||||
}),
|
||||
[
|
||||
@@ -856,6 +815,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
setShortcutsHelpVisible,
|
||||
stableSetText,
|
||||
],
|
||||
);
|
||||
@@ -1334,24 +1294,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectionWarning = () => {
|
||||
handleWarning('Press Ctrl-S to enter selection mode to copy text.');
|
||||
};
|
||||
const handlePasteTimeout = () => {
|
||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
||||
};
|
||||
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
// Handle timeout cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (warningTimeoutRef.current) {
|
||||
clearTimeout(warningTimeoutRef.current);
|
||||
}
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePasteTimeout = () => {
|
||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
||||
};
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
};
|
||||
}, [handleWarning]);
|
||||
|
||||
@@ -1518,7 +1480,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
|
||||
handleWarning('Undo has been moved to Cmd + Z or Alt/Opt + Z');
|
||||
const undoMessage = isITerm2()
|
||||
? 'Undo has been moved to Option + Z'
|
||||
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
|
||||
handleWarning(undoMessage);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
|
||||
setShowFullTodos((prev) => !prev);
|
||||
@@ -1546,71 +1511,60 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setConstrainHeight(false);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
|
||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
) {
|
||||
if (key.name === 'tab' && key.shift) {
|
||||
// Always change focus
|
||||
if (embeddedShellFocused) {
|
||||
const capturedTime = lastOutputTimeRef.current;
|
||||
if (tabFocusTimeoutRef.current)
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
if (lastOutputTimeRef.current === capturedTime) {
|
||||
setEmbeddedShellFocused(false);
|
||||
} else {
|
||||
handleWarning('Use Shift+Tab to unfocus');
|
||||
}
|
||||
}, 150);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
|
||||
|
||||
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
|
||||
if (tabFocusTimeoutRef.current)
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
toggleBackgroundShell();
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) ||
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key)
|
||||
) {
|
||||
if (embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (embeddedShellFocused) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// If the shell hasn't produced output in the last 100ms, it's considered idle.
|
||||
const isIdle = now - lastOutputTimeRef.current >= 100;
|
||||
if (isIdle && !activePtyId) {
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
toggleBackgroundShell();
|
||||
if (!isBackgroundShellVisible) {
|
||||
// We are about to show it, so focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
// We are about to hide it
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
tabFocusTimeoutRef.current = null;
|
||||
// If the shell produced output since the tab press, we assume it handled the tab
|
||||
// (e.g. autocomplete) so we should not toggle focus.
|
||||
if (lastOutputTimeRef.current > now) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return;
|
||||
}
|
||||
setEmbeddedShellFocused(false);
|
||||
}, 100);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not idle, just focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
return false;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentShell();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
if (isBackgroundShellVisible && !embeddedShellFocused) {
|
||||
toggleBackgroundShell();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
} else {
|
||||
toggleBackgroundShell();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -1653,7 +1607,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true });
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
@@ -1812,7 +1766,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const fetchBannerTexts = async () => {
|
||||
const [defaultBanner, warningBanner] = await Promise.all([
|
||||
config.getBannerTextNoCapacityIssues(),
|
||||
// TODO: temporarily disabling the banner, it will be re-added.
|
||||
'',
|
||||
config.getBannerTextCapacityIssues(),
|
||||
]);
|
||||
|
||||
@@ -1820,15 +1775,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setDefaultBannerText(defaultBanner);
|
||||
setWarningBannerText(warningBanner);
|
||||
setBannerVisible(true);
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI ||
|
||||
authType === AuthType.USE_VERTEX_AI
|
||||
) {
|
||||
setDefaultBannerText(
|
||||
'Gemini 3 Flash and Pro are now available. \nEnable "Preview features" in /settings. \nLearn more at https://goo.gle/enable-preview-features',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
@@ -1897,6 +1843,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlCPressedOnce: ctrlCPressCount >= 1,
|
||||
ctrlDPressedOnce: ctrlDPressCount >= 1,
|
||||
showEscapePrompt,
|
||||
shortcutsHelpVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
@@ -2002,6 +1949,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlCPressCount,
|
||||
ctrlDPressCount,
|
||||
showEscapePrompt,
|
||||
shortcutsHelpVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
@@ -2101,6 +2049,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
@@ -2177,6 +2126,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
|
||||
@@ -86,6 +86,11 @@ describe('directoryCommand', () => {
|
||||
settings: {
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
import type {
|
||||
ExtensionLoader,
|
||||
GeminiCLIExtension,
|
||||
@@ -15,7 +17,12 @@ import {
|
||||
completeExtensionsAndScopes,
|
||||
extensionsCommand,
|
||||
} from './extensionsCommand.js';
|
||||
import {
|
||||
ConfigExtensionDialog,
|
||||
type ConfigExtensionDialogProps,
|
||||
} from '../components/ConfigExtensionDialog.js';
|
||||
import { type CommandContext, type SlashCommand } from './types.js';
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
@@ -53,6 +60,20 @@ vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/extensionSettings.js', () => ({
|
||||
ExtensionSettingScope: {
|
||||
USER: 'user',
|
||||
WORKSPACE: 'workspace',
|
||||
},
|
||||
getScopedEnvContents: vi.fn().mockResolvedValue({}),
|
||||
promptForSetting: vi.fn(),
|
||||
updateSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('prompts', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/update.js', () => ({
|
||||
updateExtension: vi.fn(),
|
||||
checkForAllExtensionUpdates: vi.fn(),
|
||||
@@ -107,28 +128,41 @@ const allExt: GeminiCLIExtension = {
|
||||
describe('extensionsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const mockDispatchExtensionState = vi.fn();
|
||||
let mockExtensionLoader: unknown;
|
||||
let mockReloadSkills: MockedFunction<() => Promise<void>>;
|
||||
let mockReloadAgents: MockedFunction<() => Promise<void>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockExtensionLoader = Object.create(ExtensionManager.prototype);
|
||||
Object.assign(mockExtensionLoader as object, {
|
||||
enableExtension: mockEnableExtension,
|
||||
disableExtension: mockDisableExtension,
|
||||
installOrUpdateExtension: mockInstallExtension,
|
||||
uninstallExtension: mockUninstallExtension,
|
||||
getExtensions: mockGetExtensions,
|
||||
loadExtensionConfig: vi.fn().mockResolvedValue({
|
||||
name: 'test-ext',
|
||||
settings: [{ name: 'setting1', envVar: 'SETTING1' }],
|
||||
}),
|
||||
});
|
||||
|
||||
mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]);
|
||||
vi.mocked(open).mockClear();
|
||||
mockReloadAgents = vi.fn().mockResolvedValue(undefined);
|
||||
mockReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getExtensionLoader: vi.fn().mockImplementation(() => {
|
||||
const actual = Object.create(ExtensionManager.prototype);
|
||||
Object.assign(actual, {
|
||||
enableExtension: mockEnableExtension,
|
||||
disableExtension: mockDisableExtension,
|
||||
installOrUpdateExtension: mockInstallExtension,
|
||||
uninstallExtension: mockUninstallExtension,
|
||||
getExtensions: mockGetExtensions,
|
||||
});
|
||||
return actual;
|
||||
}),
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader),
|
||||
getWorkingDir: () => '/test/dir',
|
||||
reloadSkills: mockReloadSkills,
|
||||
getAgentRegistry: vi.fn().mockReturnValue({
|
||||
reload: mockReloadAgents,
|
||||
}),
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
@@ -867,6 +901,27 @@ describe('extensionsCommand', () => {
|
||||
type: 'RESTARTED',
|
||||
payload: { name: 'ext2' },
|
||||
});
|
||||
expect(mockReloadSkills).toHaveBeenCalled();
|
||||
expect(mockReloadAgents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles errors during skill or agent reload', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
] as GeminiCLIExtension[];
|
||||
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||
mockReloadSkills.mockRejectedValue(new Error('Failed to reload skills'));
|
||||
|
||||
await restartAction!(mockContext, '--all');
|
||||
|
||||
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
|
||||
expect(mockReloadSkills).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to reload skills or agents: Failed to reload skills',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts only specified active extensions', async () => {
|
||||
@@ -978,4 +1033,102 @@ describe('extensionsCommand', () => {
|
||||
expect(suggestions).toEqual(['ext1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config', () => {
|
||||
let configAction: SlashCommand['action'];
|
||||
|
||||
beforeEach(async () => {
|
||||
configAction = extensionsCommand(true).subCommands?.find(
|
||||
(cmd) => cmd.name === 'config',
|
||||
)?.action;
|
||||
|
||||
expect(configAction).not.toBeNull();
|
||||
mockContext.invocation!.name = 'config';
|
||||
|
||||
const prompts = (await import('prompts')).default;
|
||||
vi.mocked(prompts).mockResolvedValue({ overwrite: true });
|
||||
|
||||
const { getScopedEnvContents } = await import(
|
||||
'../../config/extensions/extensionSettings.js'
|
||||
);
|
||||
vi.mocked(getScopedEnvContents).mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('should return dialog to configure all extensions if no args provided', async () => {
|
||||
const result = await configAction!(mockContext, '');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.type).toBe(ConfigExtensionDialog);
|
||||
expect(component.props.configureAll).toBe(true);
|
||||
expect(component.props.extensionManager).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return dialog to configure specific extension', async () => {
|
||||
const result = await configAction!(mockContext, 'ext-one');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.type).toBe(ConfigExtensionDialog);
|
||||
expect(component.props.extensionName).toBe('ext-one');
|
||||
expect(component.props.settingKey).toBeUndefined();
|
||||
expect(component.props.configureAll).toBe(false);
|
||||
});
|
||||
|
||||
it('should return dialog to configure specific setting for an extension', async () => {
|
||||
const result = await configAction!(mockContext, 'ext-one SETTING1');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.type).toBe(ConfigExtensionDialog);
|
||||
expect(component.props.extensionName).toBe('ext-one');
|
||||
expect(component.props.settingKey).toBe('SETTING1');
|
||||
expect(component.props.scope).toBe('user'); // Default scope
|
||||
});
|
||||
|
||||
it('should respect scope argument passed to dialog', async () => {
|
||||
const result = await configAction!(
|
||||
mockContext,
|
||||
'ext-one SETTING1 --scope=workspace',
|
||||
);
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.props.scope).toBe('workspace');
|
||||
});
|
||||
|
||||
it('should show error for invalid extension name', async () => {
|
||||
await configAction!(mockContext, '../invalid');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Invalid extension name. Names cannot contain path separators or "..".',
|
||||
});
|
||||
});
|
||||
|
||||
// "should inform if extension has no settings" - This check is now inside ConfigExtensionDialog logic.
|
||||
// We can test that we still return a dialog, and the dialog will handle logical checks via utils.ts
|
||||
// For unit testing extensionsCommand, we just ensure delegation.
|
||||
it('should return dialog even if extension has no settings (dialog handles logic)', async () => {
|
||||
const result = await configAction!(mockContext, 'ext-one');
|
||||
if (result?.type !== 'custom_dialog') {
|
||||
throw new Error('Expected custom_dialog');
|
||||
}
|
||||
const dialogResult = result;
|
||||
const component =
|
||||
dialogResult.component as ReactElement<ConfigExtensionDialogProps>;
|
||||
expect(component.type).toBe(ConfigExtensionDialog);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,10 @@ import { SettingScope } from '../../config/settings.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
|
||||
import { type ConfigLogger } from '../../commands/extensions/utils.js';
|
||||
import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js';
|
||||
import React from 'react';
|
||||
|
||||
function showMessageIfNoExtensions(
|
||||
context: CommandContext,
|
||||
@@ -227,6 +231,18 @@ async function restartAction(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
if (failures.length < extensionsToRestart.length) {
|
||||
try {
|
||||
await context.services.config?.reloadSkills();
|
||||
await context.services.config?.getAgentRegistry()?.reload();
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to reload skills or agents: ${getErrorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const errorMessages = failures
|
||||
.map((failure, index) => {
|
||||
@@ -583,6 +599,77 @@ async function uninstallAction(context: CommandContext, args: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function configAction(context: CommandContext, args: string) {
|
||||
const parts = args.trim().split(/\s+/).filter(Boolean);
|
||||
let scope = ExtensionSettingScope.USER;
|
||||
|
||||
const scopeEqIndex = parts.findIndex((p) => p.startsWith('--scope='));
|
||||
if (scopeEqIndex > -1) {
|
||||
const scopeVal = parts[scopeEqIndex].split('=')[1];
|
||||
if (scopeVal === 'workspace') {
|
||||
scope = ExtensionSettingScope.WORKSPACE;
|
||||
} else if (scopeVal === 'user') {
|
||||
scope = ExtensionSettingScope.USER;
|
||||
}
|
||||
parts.splice(scopeEqIndex, 1);
|
||||
} else {
|
||||
const scopeIndex = parts.indexOf('--scope');
|
||||
if (scopeIndex > -1) {
|
||||
const scopeVal = parts[scopeIndex + 1];
|
||||
if (scopeVal === 'workspace' || scopeVal === 'user') {
|
||||
scope =
|
||||
scopeVal === 'workspace'
|
||||
? ExtensionSettingScope.WORKSPACE
|
||||
: ExtensionSettingScope.USER;
|
||||
parts.splice(scopeIndex, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const otherArgs = parts;
|
||||
const name = otherArgs[0];
|
||||
const setting = otherArgs[1];
|
||||
|
||||
if (name) {
|
||||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Invalid extension name. Names cannot contain path separators or "..".',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const extensionManager = context.services.config?.getExtensionLoader();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const logger: ConfigLogger = {
|
||||
log: (message: string) => {
|
||||
context.ui.addItem({ type: MessageType.INFO, text: message.trim() });
|
||||
},
|
||||
error: (message: string) =>
|
||||
context.ui.addItem({ type: MessageType.ERROR, text: message }),
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'custom_dialog' as const,
|
||||
component: React.createElement(ConfigExtensionDialog, {
|
||||
extensionManager,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
extensionName: name,
|
||||
settingKey: setting,
|
||||
scope,
|
||||
configureAll: !name && !setting,
|
||||
loggerAdapter: logger,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported for testing.
|
||||
*/
|
||||
@@ -701,6 +788,14 @@ const restartCommand: SlashCommand = {
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
const configCommand: SlashCommand = {
|
||||
name: 'config',
|
||||
description: 'Configure extension settings',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: configAction,
|
||||
};
|
||||
|
||||
export function extensionsCommand(
|
||||
enableExtensionReloading?: boolean,
|
||||
): SlashCommand {
|
||||
@@ -711,6 +806,7 @@ export function extensionsCommand(
|
||||
installCommand,
|
||||
uninstallCommand,
|
||||
linkCommand,
|
||||
configCommand,
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { MessageType, type HistoryItemHelp } from '../types.js';
|
||||
|
||||
export const helpCommand: SlashCommand = {
|
||||
name: 'help',
|
||||
altNames: ['?'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'For help on gemini-cli',
|
||||
autoExecute: true,
|
||||
|
||||
@@ -60,6 +60,7 @@ const createMockMCPTool = (
|
||||
{ type: 'object', properties: {} },
|
||||
mockMessageBus,
|
||||
undefined, // trust
|
||||
undefined, // isReadOnly
|
||||
undefined, // nameOverride
|
||||
undefined, // cliConfig
|
||||
undefined, // extensionName
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { planCommand } from './planCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
coreEvents,
|
||||
processSingleFileContent,
|
||||
type ProcessedFileReadResult,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
processSingleFileContent: vi.fn(),
|
||||
partToString: vi.fn((val) => val),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:path')>();
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual },
|
||||
join: vi.fn((...args) => args.join('/')),
|
||||
};
|
||||
});
|
||||
|
||||
describe('planCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
isPlanEnabled: vi.fn(),
|
||||
setApprovalMode: vi.fn(),
|
||||
getApprovedPlanPath: vi.fn(),
|
||||
getApprovalMode: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(planCommand.name).toBe('plan');
|
||||
expect(planCommand.description).toBe(
|
||||
'Switch to Plan Mode and view current plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('should switch to plan mode if enabled', async () => {
|
||||
vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true);
|
||||
vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue(
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
await planCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.services.config!.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Switched to Plan Mode.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display the approved plan from config', async () => {
|
||||
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
|
||||
vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true);
|
||||
vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue(
|
||||
mockPlanPath,
|
||||
);
|
||||
vi.mocked(processSingleFileContent).mockResolvedValue({
|
||||
llmContent: '# Approved Plan Content',
|
||||
returnDisplay: '# Approved Plan Content',
|
||||
} as ProcessedFileReadResult);
|
||||
|
||||
if (!planCommand.action) throw new Error('Action missing');
|
||||
await planCommand.action(mockContext, '');
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Approved Plan: approved-plan.md',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||
type: MessageType.GEMINI,
|
||||
text: '# Approved Plan Content',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
processSingleFileContent,
|
||||
partToString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const planCommand: SlashCommand = {
|
||||
name: 'plan',
|
||||
description: 'Switch to Plan Mode and view current plan',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const config = context.services.config;
|
||||
if (!config) {
|
||||
debugLogger.debug('Plan command: config is not available in context');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousApprovalMode = config.getApprovalMode();
|
||||
config.setApprovalMode(ApprovalMode.PLAN);
|
||||
|
||||
if (previousApprovalMode !== ApprovalMode.PLAN) {
|
||||
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
|
||||
}
|
||||
|
||||
const approvedPlanPath = config.getApprovedPlanPath();
|
||||
|
||||
if (!approvedPlanPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await processSingleFileContent(
|
||||
approvedPlanPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const fileName = path.basename(approvedPlanPath);
|
||||
|
||||
coreEvents.emitFeedback('info', `Approved Plan: ${fileName}`);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.GEMINI,
|
||||
text: partToString(content.llmContent),
|
||||
});
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual.coreEvents,
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
logRewind: vi.fn(),
|
||||
RewindEvent: class {},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
checkExhaustive,
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
logRewind,
|
||||
RewindEvent,
|
||||
type ChatRecordingService,
|
||||
type GeminiClient,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -144,6 +146,9 @@ export const rewindCommand: SlashCommand = {
|
||||
context.ui.removeComponent();
|
||||
}}
|
||||
onRewind={async (messageId, newText, outcome) => {
|
||||
if (outcome !== RewindOutcome.Cancel) {
|
||||
logRewind(config, new RewindEvent(outcome));
|
||||
}
|
||||
switch (outcome) {
|
||||
case RewindOutcome.Cancel:
|
||||
context.ui.removeComponent();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
|
||||
export const shortcutsCommand: SlashCommand = {
|
||||
name: 'shortcuts',
|
||||
altNames: [],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle the shortcuts panel above the input',
|
||||
autoExecute: true,
|
||||
action: (context) => {
|
||||
context.ui.toggleShortcutsHelp();
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user