mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 05:01:04 -07:00
Compare commits
298 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0612839ab6 | |||
| 9b2d4c6180 | |||
| a1cc5ac2b3 | |||
| a0987602b6 | |||
| 024aaf95ec | |||
| a07f40a753 | |||
| eac33bf9e7 | |||
| 589f037b2d | |||
| 11f7a6a2d6 | |||
| aaca0bfbd6 | |||
| 97f8268998 | |||
| 17f9d949ff | |||
| 163dba7e4b | |||
| 5ceae177d5 | |||
| 65e7ccd1d4 | |||
| ed1b5fe5e1 | |||
| 1ee161f3c1 | |||
| 5c6f006634 | |||
| a80cd28d4c | |||
| 46c884de51 | |||
| 4c5ab80b7e | |||
| f207ea94d6 | |||
| c913ce3c0b | |||
| f2aa9d283a | |||
| 794d92a79d | |||
| 6695c32aa2 | |||
| c0400a4414 | |||
| 0fec673bfb | |||
| 178e89a914 | |||
| d991c4607d | |||
| 62e9691373 | |||
| 3d1b0df0fa | |||
| 953935d67c | |||
| 6c54746e24 | |||
| ec08129fba | |||
| 42436d2ed4 | |||
| 1067df1872 | |||
| 6ef78cbbe9 | |||
| 1d24f95a3f | |||
| ddcbd0c2bd | |||
| ae51bbdae8 | |||
| ae387b61a9 | |||
| 6f6e004f82 | |||
| d37fff7fd6 | |||
| cea1a867b6 | |||
| d6933c77ba | |||
| 0c3fcb7030 | |||
| 94f43c79d0 | |||
| 5478b58166 | |||
| a49a09f13c | |||
| ac4a79223a | |||
| 042288e72c | |||
| 8a2c2dc73f | |||
| bf32492da9 | |||
| e8a0249e6b | |||
| ea061f52b0 | |||
| d1485d4672 | |||
| 62ba330612 | |||
| 1bd75f060d | |||
| 331e2ce45d | |||
| 0b2d79a2ea | |||
| ffcd996366 | |||
| 93694c6a65 | |||
| db51e3f4cd | |||
| 19400ba8c7 | |||
| eb1a6a6091 | |||
| 80a414be97 | |||
| 8d17d09488 | |||
| 38dccf32c1 | |||
| 24c15b9d43 | |||
| 2aa2ab878b | |||
| 969833e6e3 | |||
| 94b377049b | |||
| 0d22b22c82 | |||
| e909993dd1 | |||
| 3d7cb3fb8a | |||
| e8a065cb9f | |||
| 8a16165a9b | |||
| a4516665d5 | |||
| 11c995e9fa | |||
| 53434d860a | |||
| 2e4e53c3ee | |||
| 7e2ffd7a80 | |||
| 463e5d5b7e | |||
| 809b933d81 | |||
| 8bf8707668 | |||
| 18e5113754 | |||
| 70bc2933cc | |||
| 6535b71c35 | |||
| ed309096b1 | |||
| f885375c72 | |||
| d2d9ae3f91 | |||
| 5f080aa52a | |||
| c334f02d51 | |||
| 2d76cdf2c6 | |||
| defda3a97d | |||
| a0c8e3bf2b | |||
| e209724789 | |||
| 4caaa2a8e8 | |||
| c463d47fa8 | |||
| f80eb71068 | |||
| 135d3401cd | |||
| fab279f0fd | |||
| b1da8c210e | |||
| 9ba1640c07 | |||
| 422eb78b41 | |||
| 275a12fd45 | |||
| 8abe7e151c | |||
| 05c962af1f | |||
| 74447fcff2 | |||
| ee36307495 | |||
| 4c6da1eaf9 | |||
| 86e45c9aa5 | |||
| e0ba7e4ffb | |||
| 3660d4ecc0 | |||
| 22740ddceb | |||
| ad59be0c81 | |||
| 66c2184fe5 | |||
| e0ef5beae4 | |||
| 9d70649b75 | |||
| 4f49341ce9 | |||
| d8b895a2f1 | |||
| 1753c71bfa | |||
| b540137820 | |||
| 5cadd37eb2 | |||
| cc47e475af | |||
| 3667ecf107 | |||
| ce92ed3f68 | |||
| c6f8ecc227 | |||
| 38e053b7bb | |||
| 03bd6876d2 | |||
| 4bd4cd697b | |||
| 28c3901513 | |||
| c12b87ed03 | |||
| f795255e58 | |||
| 39b0948417 | |||
| 16278fd849 | |||
| 98461ff667 | |||
| 31c609daec | |||
| 7f9975cf1c | |||
| bfd904bfc8 | |||
| f46e50b271 | |||
| d8393a06dd | |||
| 5151bedf06 | |||
| c96f8259c1 | |||
| 82625fc07a | |||
| c93eed6384 | |||
| 5218323831 | |||
| 7e1705274c | |||
| 40db029887 | |||
| 8d637a434c | |||
| 712dc245ac | |||
| 47948e3712 | |||
| 570b0086b6 | |||
| 4cdf9207f3 | |||
| 6328ca512f | |||
| d7a0dbc6b1 | |||
| 525ced295c | |||
| 9c4d1594ac | |||
| fcffcfbacb | |||
| 59df649349 | |||
| 93e022cee5 | |||
| 710e00e02d | |||
| 4ef46e441c | |||
| bcc4d81d19 | |||
| 6c559e2338 | |||
| c0c7ad10ca | |||
| 10392ad344 | |||
| 34c14b7dd2 | |||
| 57272f84b1 | |||
| 9abb165f7b | |||
| 92543da28c | |||
| 81d03cb524 | |||
| edd988be60 | |||
| 6869dbe695 | |||
| d9828e2571 | |||
| 8fdb61aabf | |||
| b4455af306 | |||
| 56f394cefd | |||
| a1dc7a8f55 | |||
| 62b49ab14a | |||
| 375b8522fc | |||
| 2216856e3c | |||
| 468db8730a | |||
| aba23feddd | |||
| 44691a4ce6 | |||
| 22a560a990 | |||
| 0152759dfb | |||
| 37c5397319 | |||
| c564464e6b | |||
| 532497b3d1 | |||
| ab982b8e32 | |||
| 5be2a9d5e2 | |||
| f028913b0e | |||
| c8ad831039 | |||
| 3bf8fec2f1 | |||
| b9f6283a8e | |||
| 4c43c69d35 | |||
| fd64d34082 | |||
| d0da1f5fc9 | |||
| 7681c595b5 | |||
| 2bf226e918 | |||
| dd91b0a68b | |||
| fa8cea170a | |||
| 6e4236bf76 | |||
| 2c4f61eca5 | |||
| 23467cdbdb | |||
| f371ecb948 | |||
| 9f597ab7c0 | |||
| c9b39c379b | |||
| b08633daa3 | |||
| 5f5e07e6cc | |||
| 8af1439697 | |||
| 0fbc13aa4b | |||
| e232c1d5ba | |||
| e7a88f225d | |||
| 47e8822467 | |||
| 44805f4d58 | |||
| bba0f2e557 | |||
| b1fb68bb06 | |||
| fd2bfc384d | |||
| bcc1f25c53 | |||
| c442f8ae8c | |||
| 951596ffe4 | |||
| 1bfd575473 | |||
| e48f61bdc7 | |||
| 938c850ed8 | |||
| 55b6f848fc | |||
| 889a6c21ac | |||
| ecd95ed6b3 | |||
| 4e6ca95db9 | |||
| 52c1599240 | |||
| 9c392b2d44 | |||
| 42bac72be2 | |||
| 1195072aae | |||
| 09f0672734 | |||
| 00cd4b6a19 | |||
| c434a7e682 | |||
| 3f16ed5a9a | |||
| d5abb46fad | |||
| c56d0ce6d2 | |||
| cf95b631cd | |||
| 2993800e07 | |||
| e678b231a3 | |||
| a815fa37e7 | |||
| 81dcef070f | |||
| 690867e433 | |||
| 9954b03ced | |||
| ec01b1f29f | |||
| bfa4140593 | |||
| b14a4f5ebc | |||
| 29852e9b08 | |||
| 509444d059 | |||
| bbcc906133 | |||
| 22b7d86574 | |||
| e94ce7e2fd | |||
| afba59a953 | |||
| ec0acc486e | |||
| 0fcda10082 | |||
| 1f31443ed1 | |||
| a34e375193 | |||
| 899b6f72cb | |||
| db5b49b2ca | |||
| f2a47dec54 | |||
| 92c99d7873 | |||
| 2c754d71e3 | |||
| bcfd50b45b | |||
| 853ae56e7e | |||
| f41db212ec | |||
| b376df7691 | |||
| b88d9c6353 | |||
| ec9e548bd6 | |||
| 4f468a9f19 | |||
| 99b43e1302 | |||
| 6b34f38ce0 | |||
| 143e5c3140 | |||
| c9cc05bc3d | |||
| 6be59628c3 | |||
| 56b909c8e1 | |||
| 2f64307c19 | |||
| 7a68997b18 | |||
| 2d406ffc75 | |||
| 0534ca74d4 | |||
| e0fcbc39f6 | |||
| 6dca8270bc | |||
| d746eb7b22 | |||
| 930f39a0cd | |||
| 8f0306f499 | |||
| f3abfb8e66 | |||
| f8678f9e9d | |||
| 72114464b8 | |||
| 407373dcd6 | |||
| 6756a8b8a9 | |||
| d54cdd8802 | |||
| eddd13d70e | |||
| 13a65ad94f | |||
| 0cae7caaab | |||
| d2b8ff5deb |
@@ -15,37 +15,36 @@ inputs:
|
||||
description: 'The branch to merge into.'
|
||||
required: true
|
||||
default: 'main'
|
||||
app-id:
|
||||
description: 'The ID of the GitHub App.'
|
||||
required: true
|
||||
private-key:
|
||||
description: 'The private key of the GitHub App.'
|
||||
github-token:
|
||||
description: 'The GitHub token to use for creating the pull request.'
|
||||
required: true
|
||||
dry-run:
|
||||
description: 'Whether to run in dry-run mode.'
|
||||
required: false
|
||||
default: 'false'
|
||||
working-directory:
|
||||
description: 'The working directory to run the commands in.'
|
||||
required: false
|
||||
default: '.'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
if: "inputs.dry-run == 'false'"
|
||||
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b'
|
||||
with:
|
||||
app-id: '${{ inputs.app-id }}'
|
||||
private-key: '${{ inputs.private-key }}'
|
||||
permission-pull-requests: 'write'
|
||||
permission-contents: 'write'
|
||||
|
||||
- name: 'Create and Approve Pull Request'
|
||||
if: "inputs.dry-run == 'false'"
|
||||
env:
|
||||
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||
- name: '📝 Print Inputs'
|
||||
shell: 'bash'
|
||||
run: 'echo "${{ toJSON(inputs) }}"'
|
||||
- name: 'Create and Approve Pull Request'
|
||||
if: "inputs.dry-run != 'true'"
|
||||
env:
|
||||
GH_TOKEN: '${{ inputs.github-token }}'
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |
|
||||
set -e
|
||||
if ! git ls-remote --exit-code --heads origin "${{ inputs.branch-name }}"; then
|
||||
echo "::error::Branch '${{ inputs.branch-name }}' does not exist on the remote repository."
|
||||
exit 1
|
||||
fi
|
||||
PR_URL=$(gh pr create \
|
||||
--title "${{ inputs.pr-title }}" \
|
||||
--body "${{ inputs.pr-body }}" \
|
||||
|
||||
@@ -27,6 +27,9 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: '📝 Print Inputs'
|
||||
shell: 'bash'
|
||||
run: 'echo "${{ toJSON(inputs) }}"'
|
||||
- name: 'Prepare Coverage Comment'
|
||||
id: 'prep_coverage_comment'
|
||||
shell: 'bash'
|
||||
|
||||
@@ -40,14 +40,21 @@ inputs:
|
||||
description: 'Skip tests and validation'
|
||||
required: false
|
||||
default: false
|
||||
skip-branch-cleanup:
|
||||
description: 'Whether to skip cleaning up the release branch.'
|
||||
type: 'boolean'
|
||||
required: false
|
||||
default: false
|
||||
gemini_api_key:
|
||||
description: 'The API key for running integration tests.'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: '📝 Print Inputs'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
echo "${{ toJSON(inputs) }}"
|
||||
run: 'echo "${{ toJSON(inputs) }}"'
|
||||
|
||||
- name: '👤 Configure Git User'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
@@ -79,6 +86,7 @@ runs:
|
||||
DRY_RUN: '${{ inputs.dry-run }}'
|
||||
RELEASE_TAG: '${{ inputs.release-tag }}'
|
||||
run: |-
|
||||
set -e
|
||||
git add package.json package-lock.json packages/*/package.json
|
||||
git commit -m "chore(release): ${RELEASE_TAG}"
|
||||
if [[ "${DRY_RUN}" == "false" ]]; then
|
||||
@@ -108,14 +116,15 @@ runs:
|
||||
NODE_AUTH_TOKEN: '${{ inputs.wombat-token-core }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm publish \
|
||||
--dry-run="${{ inputs.dry-run }}" \
|
||||
--workspace="@google/gemini-cli-core" \
|
||||
--no-tag
|
||||
if [ "${{ inputs.dry-run }}" == "true" ]; then
|
||||
npm publish --dry-run --workspace="@google/gemini-cli-core" --no-tag
|
||||
else
|
||||
npm publish --workspace="@google/gemini-cli-core" --no-tag
|
||||
fi
|
||||
|
||||
- name: '🔗 Install latest core package'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
if: "${{ inputs.dry-run == 'false' }}"
|
||||
if: "${{ inputs.dry-run != 'true' }}"
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm install "@google/gemini-cli-core@${{ inputs.release-version }}" \
|
||||
@@ -128,22 +137,24 @@ runs:
|
||||
NODE_AUTH_TOKEN: '${{ inputs.wombat-token-cli }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm publish \
|
||||
--dry-run="${{ inputs.dry-run }}" \
|
||||
--workspace="@google/gemini-cli" \
|
||||
--no-tag
|
||||
if [ "${{ inputs.dry-run }}" == "true" ]; then
|
||||
npm publish --dry-run --workspace="@google/gemini-cli" --no-tag
|
||||
else
|
||||
npm publish --workspace="@google/gemini-cli" --no-tag
|
||||
fi
|
||||
|
||||
- name: '🔬 Verify NPM release by version'
|
||||
uses: './.github/actions/verify-release'
|
||||
if: "${{ inputs.dry-run == 'false' && inputs.force-skip-tests == 'false' }}"
|
||||
if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
|
||||
with:
|
||||
npm-package: '@google/gemini-cli@${{ inputs.release-version }}'
|
||||
expected-version: '${{ inputs.release-version }}'
|
||||
ref: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
gemini_api_key: '${{ inputs.gemini_api_key }}'
|
||||
|
||||
- name: '🏷️ Tag release'
|
||||
uses: './.github/actions/tag-npm-release'
|
||||
if: "${{ inputs.dry-run == 'false' }}"
|
||||
if: "${{ inputs.dry-run != 'true' }}"
|
||||
with:
|
||||
channel: '${{ inputs.npm-tag }}'
|
||||
version: '${{ inputs.release-version }}'
|
||||
@@ -151,12 +162,6 @@ runs:
|
||||
wombat-token-core: '${{ inputs.wombat-token-core }}'
|
||||
wombat-token-cli: '${{ inputs.wombat-token-cli }}'
|
||||
|
||||
- name: 'Install deps'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm install
|
||||
|
||||
- name: '🎁 Bundle'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
shell: 'bash'
|
||||
@@ -165,7 +170,7 @@ runs:
|
||||
|
||||
- name: '🎉 Create GitHub Release'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
if: "${{ inputs.dry-run == 'false' && inputs.skip-github-release == 'false' && inputs.npm-tag != 'dev' }}"
|
||||
if: "${{ inputs.dry-run != 'true' && inputs.skip-github-release != 'true' && inputs.npm-tag != 'dev' }}"
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ inputs.github-token }}'
|
||||
shell: 'bash'
|
||||
@@ -179,7 +184,7 @@ runs:
|
||||
|
||||
- name: '🧹 Clean up release branch'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
if: "${{ inputs.dry-run == 'false' }}"
|
||||
if: "${{ inputs.dry-run != 'true' && inputs.skip-branch-cleanup != 'true' }}"
|
||||
continue-on-error: true
|
||||
shell: 'bash'
|
||||
run: |
|
||||
|
||||
@@ -18,6 +18,9 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: '📝 Print Inputs'
|
||||
shell: 'bash'
|
||||
run: 'echo "${{ toJSON(inputs) }}"'
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
|
||||
@@ -8,6 +8,12 @@ inputs:
|
||||
github-secret:
|
||||
description: 'Github secret'
|
||||
required: true
|
||||
dockerhub-username:
|
||||
description: 'Dockerhub username'
|
||||
required: true
|
||||
dockerhub-token:
|
||||
description: 'Dockerhub PAT w/ R+W'
|
||||
required: true
|
||||
github-sha:
|
||||
description: 'Github Commit SHA Hash'
|
||||
required: true
|
||||
@@ -22,6 +28,9 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: '📝 Print Inputs'
|
||||
shell: 'bash'
|
||||
run: 'echo "${{ toJSON(inputs) }}"'
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@v4'
|
||||
with:
|
||||
@@ -38,9 +47,9 @@ runs:
|
||||
- name: 'Log in to GitHub Container Registry'
|
||||
uses: 'docker/login-action@v3'
|
||||
with:
|
||||
registry: 'ghcr.io'
|
||||
username: '${{ inputs.github-actor }}'
|
||||
password: '${{ inputs.github-secret }}'
|
||||
registry: 'docker.io'
|
||||
username: '${{ inputs.dockerhub-username }}'
|
||||
password: '${{ inputs.dockerhub-token }}'
|
||||
- name: 'determine image tag'
|
||||
id: 'image_tag'
|
||||
shell: 'bash'
|
||||
@@ -63,12 +72,12 @@ runs:
|
||||
GEMINI_SANDBOX: 'docker'
|
||||
run: |-
|
||||
npm run build:sandbox -- \
|
||||
--image ghcr.io/${{ github.repository}}/sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \
|
||||
--image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \
|
||||
--output-file final_image_uri.txt
|
||||
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
|
||||
- name: 'publish'
|
||||
shell: 'bash'
|
||||
if: "${{ inputs.dry-run == 'false' }}"
|
||||
if: "${{ inputs.dry-run != 'true' }}"
|
||||
run: |-
|
||||
docker push "${{ steps.docker_build.outputs.uri }}"
|
||||
- name: 'Create issue on failure'
|
||||
@@ -2,11 +2,6 @@ name: 'Run Tests'
|
||||
description: 'Runs the preflight checks and integration tests.'
|
||||
|
||||
inputs:
|
||||
force_skip_tests:
|
||||
description: 'Whether to force skip the tests.'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
gemini_api_key:
|
||||
description: 'The API key for running integration tests.'
|
||||
required: true
|
||||
@@ -18,14 +13,24 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: '📝 Print Inputs'
|
||||
shell: 'bash'
|
||||
run: 'echo "${{ toJSON(inputs) }}"'
|
||||
- name: 'Run Tests'
|
||||
if: '${{ !inputs.force_skip_tests }}'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
echo "::group::Build"
|
||||
npm run build
|
||||
echo "::endgroup::"
|
||||
echo "::group::Unit Tests"
|
||||
npm run test:ci
|
||||
echo "::endgroup::"
|
||||
echo "::group::Integration Tests (no sandbox)"
|
||||
npm run test:integration:sandbox:none
|
||||
echo "::endgroup::"
|
||||
echo "::group::Integration Tests (docker sandbox)"
|
||||
npm run test:integration:sandbox:docker
|
||||
echo "::endgroup::"
|
||||
shell: 'bash'
|
||||
|
||||
@@ -20,6 +20,10 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: '📝 Print Inputs'
|
||||
shell: 'bash'
|
||||
run: 'echo "${{ toJSON(inputs) }}"'
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
|
||||
with:
|
||||
@@ -29,7 +33,7 @@ runs:
|
||||
|
||||
- name: 'Change tag for @google/gemini-cli-core'
|
||||
if: |-
|
||||
${{ inputs.dry-run == 'false' }}
|
||||
${{ inputs.dry-run != 'true' }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: '${{ inputs.wombat-token-core }}'
|
||||
shell: 'bash'
|
||||
@@ -38,7 +42,7 @@ runs:
|
||||
|
||||
- name: 'Change tag for @google/gemini-cli'
|
||||
if: |-
|
||||
${{ inputs.dry-run == 'false' }}
|
||||
${{ inputs.dry-run != 'true' }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: '${{ inputs.wombat-token-cli }}'
|
||||
shell: 'bash'
|
||||
|
||||
@@ -9,6 +9,9 @@ inputs:
|
||||
expected-version:
|
||||
description: 'Expected version'
|
||||
required: true
|
||||
gemini_api_key:
|
||||
description: 'The API key for running integration tests.'
|
||||
required: true
|
||||
ref:
|
||||
description: 'The branch, tag, or SHA to release from.'
|
||||
required: false
|
||||
@@ -20,11 +23,12 @@ runs:
|
||||
steps:
|
||||
- name: '📝 Print Inputs'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
echo "${{ toJSON(inputs) }}"
|
||||
run: 'echo "${{ toJSON(inputs) }}"'
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
path: 'verify'
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -34,22 +38,27 @@ runs:
|
||||
timeout_seconds: 900
|
||||
retry_wait_seconds: 30
|
||||
max_attempts: 10
|
||||
command: |-
|
||||
npm install --prefer-online --no-cache -g ${{ inputs.npm-package }}
|
||||
command: 'cd ./verify && pkg="${{ inputs.npm-package }}" && npm install --prefer-online --no-cache -g "$pkg"'
|
||||
|
||||
# This provides a very basic smoke test for Gemini CLI
|
||||
- name: 'Run Gemini CLI'
|
||||
id: 'gemini_cli'
|
||||
- name: 'Basic smoke test'
|
||||
shell: 'bash'
|
||||
working-directory: './verify'
|
||||
run: |-
|
||||
echo "gemini_version=$(gemini --version)" >> $GITHUB_OUTPUT
|
||||
gemini_version=$(gemini --version)
|
||||
if [ "$gemini_version" != "${{ inputs.expected-version }}" ]; then
|
||||
echo "❌ Version mismatch: Got $gemini_version from ${{ inputs.npm-package }}, expected ${{ inputs.expected-version }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Force a failure if it doesn't match
|
||||
- name: 'Fail workflow if version does not match'
|
||||
if: '${{ steps.gemini_cli.outputs.gemini_version != inputs.expected-version }}'
|
||||
- name: 'Install dependencies for integration tests'
|
||||
shell: 'bash'
|
||||
run: |-
|
||||
echo '❌ Got ${{ steps.gemini_cli.outputs.gemini_version }} from ${{ inputs.npm-package }}'
|
||||
echo '❌ Expected Version ${{ inputs.expected-version }}'
|
||||
working-directory: './verify'
|
||||
run: 'npm install'
|
||||
|
||||
exit 1
|
||||
- name: '🔬 Run integration tests against NPM release'
|
||||
working-directory: './verify'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
|
||||
INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
|
||||
shell: 'bash'
|
||||
run: 'npm run test:integration:sandbox:none'
|
||||
|
||||
@@ -11,6 +11,12 @@ on:
|
||||
- 'release/**'
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch_ref:
|
||||
description: 'Branch to run on'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: 'string'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
@@ -27,13 +33,27 @@ defaults:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
merge_queue_skipper:
|
||||
name: 'Merge Queue Skipper'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
outputs:
|
||||
skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}'
|
||||
steps:
|
||||
- id: 'merge-queue-ci-skipper'
|
||||
uses: 'cariad-tech/merge-queue-ci-skipper@1032489e59437862c90a08a2c92809c903883772' # ratchet:cariad-tech/merge-queue-ci-skipper@main
|
||||
with:
|
||||
secret: '${{ secrets.GITHUB_TOKEN }}'
|
||||
|
||||
lint:
|
||||
name: 'Lint'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
@@ -71,6 +91,8 @@ jobs:
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
needs:
|
||||
- 'lint'
|
||||
- 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
permissions:
|
||||
contents: 'read'
|
||||
checks: 'write'
|
||||
@@ -123,11 +145,13 @@ jobs:
|
||||
name: 'test-results-fork-${{ matrix.node-version }}-${{ runner.os }}'
|
||||
path: 'packages/*/junit.xml'
|
||||
|
||||
test_slow_platforms:
|
||||
name: 'Slow Test - Mac'
|
||||
test_mac:
|
||||
name: 'Test (Mac)'
|
||||
runs-on: '${{ matrix.os }}'
|
||||
needs:
|
||||
- 'lint'
|
||||
- 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
permissions:
|
||||
contents: 'read'
|
||||
checks: 'write'
|
||||
@@ -194,6 +218,8 @@ jobs:
|
||||
codeql:
|
||||
name: 'CodeQL'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
permissions:
|
||||
actions: 'read'
|
||||
contents: 'read'
|
||||
@@ -201,6 +227,8 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
|
||||
- name: 'Initialize CodeQL'
|
||||
uses: 'github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/init@v3
|
||||
@@ -213,7 +241,8 @@ jobs:
|
||||
# Check for changes in bundle size.
|
||||
bundle_size:
|
||||
name: 'Check Bundle Size'
|
||||
if: "github.event_name == 'pull_request'"
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
permissions:
|
||||
contents: 'read' # For checkout
|
||||
@@ -223,6 +252,7 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: 'preactjs/compressed-size-action@946a292cd35bd1088e0d7eb92b69d1a8d5b5d76a'
|
||||
@@ -236,11 +266,15 @@ jobs:
|
||||
test_windows:
|
||||
name: 'Slow Test - Win'
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
|
||||
- name: 'Set up Node.js 20.x'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
@@ -294,6 +328,7 @@ jobs:
|
||||
needs:
|
||||
- 'lint'
|
||||
- 'test_linux'
|
||||
- 'test_mac'
|
||||
- 'codeql'
|
||||
- 'bundle_size'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
@@ -302,6 +337,7 @@ jobs:
|
||||
run: |
|
||||
if [[ (${{ needs.lint.result }} != 'success' && ${{ needs.lint.result }} != 'skipped') || \
|
||||
(${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \
|
||||
(${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \
|
||||
(${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \
|
||||
(${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then
|
||||
echo "One or more CI jobs failed."
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
name: 'Trigger Docs Rebuild'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'docs/**'
|
||||
jobs:
|
||||
trigger-rebuild:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Trigger rebuild'
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' \
|
||||
"${{ secrets.DOCS_REBUILD_URL }}"
|
||||
+71
-86
@@ -13,6 +13,13 @@ on:
|
||||
pull_request_target:
|
||||
types: ['labeled']
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch_ref:
|
||||
description: 'Branch to run on'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: 'string'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
|
||||
@@ -20,48 +27,27 @@ concurrency:
|
||||
${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 'Build Project'
|
||||
merge_queue_skipper:
|
||||
name: 'Merge Queue Skipper'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event.label.name == 'maintainer:e2e:ok')
|
||||
outputs:
|
||||
skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
- id: 'merge-queue-e2e-skipper'
|
||||
uses: 'cariad-tech/merge-queue-ci-skipper@1032489e59437862c90a08a2c92809c903883772' # ratchet:cariad-tech/merge-queue-ci-skipper@main
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build project'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Archive build artifacts'
|
||||
run: 'tar -cvf build-artifacts.tar bundle/ node_modules/ packages/ package.json'
|
||||
|
||||
- name: 'Upload build artifacts'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02'
|
||||
with:
|
||||
name: 'build-artifacts-${{ github.run_id }}'
|
||||
path: 'build-artifacts.tar'
|
||||
secret: '${{ secrets.GITHUB_TOKEN }}'
|
||||
|
||||
e2e_linux:
|
||||
name: 'E2E Test (Linux) - ${{ matrix.sandbox }}'
|
||||
needs:
|
||||
- 'build'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
needs.merge_queue_skipper.outputs.skip == 'false' &&
|
||||
(github.event_name == 'push' ||
|
||||
github.event_name == 'merge_group' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event.label.name == 'maintainer:e2e:ok')
|
||||
(github.event.label.name == 'maintainer:e2e:ok'))
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -74,30 +60,29 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 'Checkout (fork)'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
if: "github.event_name == 'pull_request_target'"
|
||||
with:
|
||||
ref: '${{ github.event.pull_request.head.sha }}'
|
||||
repository: '${{ github.event.pull_request.head.repo.full_name }}'
|
||||
|
||||
- name: 'Checkout (internal)'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
if: "github.event_name != 'pull_request_target'"
|
||||
|
||||
- name: 'Download build artifacts'
|
||||
uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'
|
||||
with:
|
||||
name: 'build-artifacts-${{ github.run_id }}'
|
||||
path: '.'
|
||||
|
||||
- name: 'Extract build artifacts'
|
||||
run: 'tar -xvf build-artifacts.tar'
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
|
||||
- name: 'Set up Node.js ${{ matrix.node-version }}'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
with:
|
||||
node-version: '${{ matrix.node-version }}'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build project'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Set up Docker'
|
||||
if: "matrix.sandbox == 'sandbox:docker'"
|
||||
uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3
|
||||
@@ -106,67 +91,55 @@ jobs:
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: '${{ matrix.sandbox }}'
|
||||
VERBOSE: 'true'
|
||||
GEMINI_SANDBOX: 'docker'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then
|
||||
npm run build:sandbox
|
||||
npm run test:integration:sandbox:docker
|
||||
else
|
||||
npm run test:integration:sandbox:none
|
||||
fi
|
||||
npx vitest run --root ./integration-tests
|
||||
|
||||
e2e_slow_platforms:
|
||||
name: 'Slow E2E - Mac'
|
||||
needs:
|
||||
- 'build'
|
||||
e2e_mac:
|
||||
name: 'E2E Test (macOS)'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
needs.merge_queue_skipper.outputs.skip == 'false' &&
|
||||
(github.event_name == 'push' ||
|
||||
github.event_name == 'merge_group' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event.label.name == 'maintainer:e2e:ok')
|
||||
runs-on: '${{ matrix.os }}'
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- 'macos-latest'
|
||||
node-version:
|
||||
- '20.x'
|
||||
|
||||
(github.event.label.name == 'maintainer:e2e:ok'))
|
||||
runs-on: 'macos-latest'
|
||||
steps:
|
||||
- name: 'Checkout (fork)'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
if: "github.event_name == 'pull_request_target'"
|
||||
with:
|
||||
ref: '${{ github.event.pull_request.head.sha }}'
|
||||
repository: '${{ github.event.pull_request.head.repo.full_name }}'
|
||||
|
||||
- name: 'Checkout (internal)'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
if: "github.event_name != 'pull_request_target'"
|
||||
|
||||
- name: 'Download build artifacts'
|
||||
uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'
|
||||
with:
|
||||
name: 'build-artifacts-${{ github.run_id }}'
|
||||
path: '.'
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
|
||||
- name: 'Extract build artifacts'
|
||||
run: 'tar -xvf build-artifacts.tar'
|
||||
|
||||
- name: 'Set up Node.js ${{ matrix.node-version }}'
|
||||
- name: 'Set up Node.js 20.x'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
with:
|
||||
node-version: '${{ matrix.node-version }}'
|
||||
node-version: '20.x'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build project'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Fix rollup optional dependencies on macOS'
|
||||
if: "runner.os == 'macOS'"
|
||||
run: |
|
||||
npm cache clean --force
|
||||
npm install --no-save @rollup/rollup-darwin-arm64 || true
|
||||
|
||||
- name: 'Run E2E tests (non-Windows)'
|
||||
if: "runner.os != 'Windows'"
|
||||
env:
|
||||
@@ -174,29 +147,34 @@ jobs:
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
run: 'npx vitest run --root ./integration-tests'
|
||||
run: 'npm run test:integration:sandbox:none'
|
||||
|
||||
e2e_windows:
|
||||
name: 'Slow E2E - Win'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
needs.merge_queue_skipper.outputs.skip == 'false' &&
|
||||
(github.event_name == 'push' ||
|
||||
github.event_name == 'merge_group' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event.label.name == 'maintainer:e2e:ok')
|
||||
(github.event.label.name == 'maintainer:e2e:ok'))
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- name: 'Checkout (fork)'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
if: "github.event_name == 'pull_request_target'"
|
||||
with:
|
||||
ref: '${{ github.event.pull_request.head.sha }}'
|
||||
repository: '${{ github.event.pull_request.head.repo.full_name }}'
|
||||
|
||||
- name: 'Checkout (internal)'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
if: "github.event_name != 'pull_request_target'"
|
||||
with:
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
|
||||
- name: 'Set up Node.js 20.x'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
@@ -226,6 +204,10 @@ jobs:
|
||||
run: 'npm ci'
|
||||
shell: 'pwsh'
|
||||
|
||||
- name: 'Build project'
|
||||
run: 'npm run build'
|
||||
shell: 'pwsh'
|
||||
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
@@ -236,7 +218,7 @@ jobs:
|
||||
UV_THREADPOOL_SIZE: '32'
|
||||
NODE_ENV: 'test'
|
||||
shell: 'pwsh'
|
||||
run: 'npx vitest run --root ./integration-tests'
|
||||
run: 'npm run test:integration:sandbox:none'
|
||||
|
||||
e2e:
|
||||
name: 'E2E'
|
||||
@@ -244,17 +226,20 @@ jobs:
|
||||
always() && (
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'merge_group' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event.label.name == 'maintainer:e2e:ok')
|
||||
)
|
||||
needs:
|
||||
- 'e2e_linux'
|
||||
- 'e2e_mac'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
steps:
|
||||
- name: 'Check E2E test results'
|
||||
run: |
|
||||
if [[ ${{ needs.e2e_linux.result }} != 'success' ]]; then
|
||||
echo "The required E2E test job failed."
|
||||
if [[ (${{ needs.e2e_linux.result }} != 'success' && ${{ needs.e2e_linux.result }} != 'skipped') || \
|
||||
(${{ needs.e2e_mac.result }} != 'success' && ${{ needs.e2e_mac.result }} != 'skipped') ]]; then
|
||||
echo "One or more E2E jobs failed."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required E2E test jobs passed!"
|
||||
echo "All required E2E jobs passed!"
|
||||
|
||||
@@ -48,9 +48,18 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Checkout Release Code'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
path: 'release'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Debug Inputs'
|
||||
run: 'echo "${{ toJSON(inputs) }}"'
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
|
||||
with:
|
||||
@@ -58,21 +67,23 @@ jobs:
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
working-directory: './release'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Prepare Release Info'
|
||||
id: 'release_info'
|
||||
working-directory: './release'
|
||||
run: |
|
||||
RELEASE_VERSION="${{ github.event.inputs.version }}"
|
||||
echo "RELEASE_VERSION=${RELEASE_VERSION#v}" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIOUS_TAG=$(git describe --tags --abbrev=0)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != true }}
|
||||
if: "${{github.event.inputs.force_skip_tests != 'true'}}"
|
||||
uses: './.github/actions/run-tests'
|
||||
with:
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
working-directory: './release'
|
||||
|
||||
- name: 'Publish Release'
|
||||
uses: './.github/actions/publish-release'
|
||||
@@ -87,3 +98,17 @@ jobs:
|
||||
dry-run: '${{ github.event.inputs.dry_run }}'
|
||||
previous-tag: '${{ steps.release_info.outputs.PREVIOUS_TAG }}'
|
||||
skip-github-release: '${{ github.event.inputs.skip_github_release }}'
|
||||
working-directory: './release'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: '${{ failure() && github.event.inputs.dry_run == false }}'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_TAG: '${{ github.event.inputs.version }}'
|
||||
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |
|
||||
gh issue create \
|
||||
--title 'Manual Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \
|
||||
--body 'The manual release workflow failed. See the full run for details: ${DETAILS_URL}' \
|
||||
--label 'kind/bug,release-failure,priority/p0'
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
default: true
|
||||
ref:
|
||||
description: 'The branch, tag, or SHA to release from.'
|
||||
required: false
|
||||
@@ -31,38 +31,66 @@ jobs:
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Checkout Release Code'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
path: 'release'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
node-version-file: './release/.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
working-directory: './release'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Print Inputs'
|
||||
run: |
|
||||
echo "${{ toJSON(github.event.inputs) }}"
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: "${{github.event.inputs.force_skip_tests == 'false'}}"
|
||||
uses: './.github/actions/run-tests'
|
||||
with:
|
||||
force_skip_tests: '${{ github.event.inputs.force_skip_tests }}'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
working-directory: './release'
|
||||
|
||||
- name: 'Get Nightly Version'
|
||||
id: 'nightly_version'
|
||||
working-directory: './release'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
# Calculate the version using the centralized script
|
||||
VERSION_JSON=$(node scripts/get-release-version.js --type=nightly)
|
||||
echo "RELEASE_TAG=$(echo "${VERSION_JSON}" | jq -r .releaseTag)" >> "${GITHUB_OUTPUT}"
|
||||
echo "RELEASE_VERSION=$(echo "${VERSION_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
|
||||
echo "NPM_TAG=$(echo "${VERSION_JSON}" | jq -r .npmTag)" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIOUS_TAG=$(echo "${VERSION_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Extract values for logging and outputs
|
||||
RELEASE_TAG=$(echo "${VERSION_JSON}" | jq -r .releaseTag)
|
||||
RELEASE_VERSION=$(echo "${VERSION_JSON}" | jq -r .releaseVersion)
|
||||
NPM_TAG=$(echo "${VERSION_JSON}" | jq -r .npmTag)
|
||||
PREVIOUS_TAG=$(echo "${VERSION_JSON}" | jq -r .previousReleaseTag)
|
||||
|
||||
# Print calculated values for logging
|
||||
echo "Calculated Release Tag: ${RELEASE_TAG}"
|
||||
echo "Calculated Release Version: ${RELEASE_VERSION}"
|
||||
echo "Calculated Previous Tag: ${PREVIOUS_TAG}"
|
||||
|
||||
# Set outputs for subsequent steps
|
||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "${GITHUB_OUTPUT}"
|
||||
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
echo "NPM_TAG=${NPM_TAG}" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Publish Release'
|
||||
if: true
|
||||
uses: './.github/actions/publish-release'
|
||||
with:
|
||||
release-version: '${{ steps.nightly_version.outputs.RELEASE_VERSION }}'
|
||||
@@ -73,6 +101,9 @@ jobs:
|
||||
github-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
dry-run: '${{ github.event.inputs.dry_run }}'
|
||||
previous-tag: '${{ steps.nightly_version.outputs.PREVIOUS_TAG }}'
|
||||
working-directory: './release'
|
||||
skip-branch-cleanup: true
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
|
||||
- name: 'Create and Merge Pull Request'
|
||||
uses: './.github/actions/create-pull-request'
|
||||
@@ -80,18 +111,18 @@ jobs:
|
||||
branch-name: 'release/${{ steps.nightly_version.outputs.RELEASE_TAG }}'
|
||||
pr-title: 'chore(release): bump version to ${{ steps.nightly_version.outputs.RELEASE_VERSION }}'
|
||||
pr-body: 'Automated version bump for nightly release.'
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
dry-run: '${{ github.event.inputs.dry_run }}'
|
||||
working-directory: './release'
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: '${{ failure() && github.event.inputs.dry_run == false }}'
|
||||
if: "${{ failure() && github.event.inputs.dry_run != 'true' }}"
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
RELEASE_TAG: '${{ steps.nightly_version.outputs.RELEASE_TAG }}'
|
||||
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |
|
||||
gh issue create \
|
||||
--title 'Nightly Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \
|
||||
--body 'The nightly-release workflow failed. See the full run for details: ${DETAILS_URL}' \
|
||||
--title "Nightly Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
|
||||
--body "The nightly-release workflow failed. See the full run for details: ${DETAILS_URL}" \
|
||||
--label 'kind/bug,release-failure,priority/p0'
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
name: 'Release: Patch from Comment'
|
||||
name: 'Release: Patch (0) from Comment'
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -52,15 +52,6 @@ jobs:
|
||||
- name: 'Install Script Dependencies'
|
||||
run: 'npm install yargs'
|
||||
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b'
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
permission-pull-requests: 'write'
|
||||
permission-contents: 'write'
|
||||
|
||||
- name: 'Configure Git User'
|
||||
run: |-
|
||||
git config user.name "gemini-cli-robot"
|
||||
@@ -72,12 +63,12 @@ jobs:
|
||||
id: 'create_patch'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Capture output and display it in logs using tee
|
||||
{
|
||||
node scripts/releasing/create-patch-pr.js --commit=${{ github.event.inputs.commit }} --channel=${{ github.event.inputs.channel }} --dry-run=${{ github.event.inputs.dry_run }}
|
||||
node scripts/releasing/create-patch-pr.js --commit=${{ github.event.inputs.commit }} --channel=${{ github.event.inputs.channel }} --pullRequestNumber=${{ github.event.inputs.original_pr }} --dry-run=${{ github.event.inputs.dry_run }}
|
||||
echo "EXIT_CODE=$?" >> "$GITHUB_OUTPUT"
|
||||
} 2>&1 | tee >(
|
||||
echo "LOG_CONTENT<<EOF" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -32,6 +32,11 @@ on:
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
test_mode:
|
||||
description: 'Whether or not to run in test mode'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
trigger-patch-release:
|
||||
@@ -68,5 +73,6 @@ jobs:
|
||||
GITHUB_EVENT_NAME: '${{ github.event_name }}'
|
||||
GITHUB_EVENT_PAYLOAD: '${{ toJSON(github.event) }}'
|
||||
FORCE_SKIP_TESTS: '${{ github.event.inputs.force_skip_tests }}'
|
||||
TEST_MODE: '${{ github.event.inputs.test_mode }}'
|
||||
run: |
|
||||
node scripts/releasing/patch-trigger.js
|
||||
node scripts/releasing/patch-trigger.js --dry-run=${{ github.event.inputs.dry_run }}
|
||||
|
||||
@@ -144,9 +144,9 @@ jobs:
|
||||
echo " Previous Tag: ${{ steps.patch_version.outputs.PREVIOUS_TAG }}"
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: "${{github.event.inputs.force_skip_tests != 'true'}}"
|
||||
uses: './.github/actions/run-tests'
|
||||
with:
|
||||
force_skip_tests: '${{ github.event.inputs.force_skip_tests }}'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
working-directory: './release'
|
||||
|
||||
@@ -162,6 +162,7 @@ jobs:
|
||||
dry-run: '${{ github.event.inputs.dry_run }}'
|
||||
previous-tag: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}'
|
||||
working-directory: './release'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: '${{ failure() && github.event.inputs.dry_run == false }}'
|
||||
|
||||
@@ -68,12 +68,15 @@ jobs:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
set -e
|
||||
STABLE_JSON=$(node scripts/get-release-version.js --type=stable ${{ github.event.inputs.stable_version_override && format('--stable_version_override={0}', github.event.inputs.stable_version_override) || '' }})
|
||||
PREVIEW_JSON=$(node scripts/get-release-version.js --type=preview ${{ github.event.inputs.preview_version_override && format('--preview_version_override={0}', github.event.inputs.preview_version_override) || '' }})
|
||||
NIGHTLY_JSON=$(node scripts/get-release-version.js --type=nightly)
|
||||
echo "STABLE_JSON_COMMAND=node scripts/get-release-version.js --type=stable ${{ github.event.inputs.stable_version_override && format('--stable_version_override={0}', github.event.inputs.stable_version_override) || '' }}"
|
||||
echo "PREVIEW_JSON_COMMAND=node scripts/get-release-version.js --type=preview ${{ github.event.inputs.preview_version_override && format('--preview_version_override={0}', github.event.inputs.preview_version_override) || '' }}"
|
||||
echo "NIGHTLY_JSON_COMMAND=node scripts/get-release-version.js --type=nightly"
|
||||
STABLE_COMMAND="node scripts/get-release-version.js --type=stable ${{ github.event.inputs.stable_version_override && format('--stable_version_override={0}', github.event.inputs.stable_version_override) || '' }}"
|
||||
PREVIEW_COMMAND="node scripts/get-release-version.js --type=preview ${{ github.event.inputs.preview_version_override && format('--preview_version_override={0}', github.event.inputs.preview_version_override) || '' }}"
|
||||
NIGHTLY_COMMAND="node scripts/get-release-version.js --type=promote-nightly"
|
||||
STABLE_JSON=$(${STABLE_COMMAND})
|
||||
PREVIEW_JSON=$(${PREVIEW_COMMAND})
|
||||
NIGHTLY_JSON=$(${NIGHTLY_COMMAND})
|
||||
echo "STABLE_JSON_COMMAND=${STABLE_COMMAND}"
|
||||
echo "PREVIEW_JSON_COMMAND=${PREVIEW_COMMAND}"
|
||||
echo "NIGHTLY_JSON_COMMAND=${NIGHTLY_COMMAND}"
|
||||
echo "STABLE_JSON: ${STABLE_JSON}"
|
||||
echo "PREVIEW_JSON: ${PREVIEW_JSON}"
|
||||
echo "NIGHTLY_JSON: ${NIGHTLY_JSON}"
|
||||
@@ -83,25 +86,39 @@ jobs:
|
||||
echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
echo "PREVIEW_SHA=$(git rev-parse '${{ github.event.inputs.ref }}'^{commit})" >> "${GITHUB_OUTPUT}"
|
||||
REF="${{ github.event.inputs.ref }}"
|
||||
SHA=$(git ls-remote origin "$REF" | awk '{print $1}')
|
||||
if [ -z "$SHA" ]; then
|
||||
if [[ "$REF" =~ ^[0-9a-f]{7,40}$ ]]; then
|
||||
SHA="$REF"
|
||||
else
|
||||
echo "::error::Could not resolve ref '$REF' to a commit SHA."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "PREVIEW_SHA=$SHA" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
|
||||
echo "NEXT_NIGHTLY_VERSION=$(echo "${NIGHTLY_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIOUS_NIGHTLY_TAG=$(echo "${NIGHTLY_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
|
||||
CURRENT_NIGHTLY_TAG=$(git describe --tags --abbrev=0 --match="*nightly*")
|
||||
echo "CURRENT_NIGHTLY_TAG=${CURRENT_NIGHTLY_TAG}" >> "${GITHUB_OUTPUT}"
|
||||
echo "NEXT_SHA=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"
|
||||
echo "NEXT_SHA=$SHA" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Display Pending Updates'
|
||||
run: |
|
||||
echo "Pending Changes, Next Versions:"
|
||||
echo " Nightly: ${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}"
|
||||
echo " Preview: ${{ steps.versions.outputs.PREVIEW_VERSION }}"
|
||||
echo " Stable: ${{ steps.versions.outputs.STABLE_VERSION }}"
|
||||
echo "Release Plan:"
|
||||
echo "-----------"
|
||||
echo "Stable Release: ${{ steps.versions.outputs.STABLE_VERSION }}"
|
||||
echo " - Commit: ${{ steps.versions.outputs.STABLE_SHA }}"
|
||||
echo " - Previous Tag: ${{ steps.versions.outputs.PREVIOUS_STABLE_TAG }}"
|
||||
echo ""
|
||||
echo "Relevant SHAs:"
|
||||
echo " Stable: Will be cut from: : ${{ steps.versions.outputs.STABLE_SHA }} / ${{ steps.versions.outputs.PREVIOUS_STABLE_TAG }}"
|
||||
echo " Preview will be cut from: : ${{ steps.versions.outputs.PREVIEW_SHA }} (${{ github.event.inputs.ref }})". Users last saw preview as: ${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }}
|
||||
echo " Nightly will be udpated in : ${{ steps.versions.outputs.NEXT_SHA }} (${{ github.event.inputs.ref }}). Previous nightly tag: ${{ steps.versions.outputs.PREVIOUS_NIGHTLY_TAG }}"
|
||||
echo "Preview Release: ${{ steps.versions.outputs.PREVIEW_VERSION }}"
|
||||
echo " - Commit: ${{ steps.versions.outputs.PREVIEW_SHA }} (${{ github.event.inputs.ref }})"
|
||||
echo " - Previous Tag: ${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }}"
|
||||
echo ""
|
||||
echo "Next Nightly Release: ${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}"
|
||||
echo " - Commit: ${{ steps.versions.outputs.NEXT_SHA }} (${{ github.event.inputs.ref }})"
|
||||
echo " - Previous Tag: ${{ steps.versions.outputs.PREVIOUS_NIGHTLY_TAG }}"
|
||||
|
||||
test:
|
||||
name: 'Test ${{ matrix.channel }}'
|
||||
@@ -141,9 +158,9 @@ jobs:
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: "${{github.event.inputs.force_skip_tests != 'true'}}"
|
||||
uses: './.github/actions/run-tests'
|
||||
with:
|
||||
force_skip_tests: '${{ github.event.inputs.force_skip_tests }}'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
working-directory: './release'
|
||||
|
||||
@@ -190,6 +207,8 @@ jobs:
|
||||
dry-run: '${{ github.event.inputs.dry_run }}'
|
||||
previous-tag: '${{ needs.calculate-versions.outputs.PREVIOUS_PREVIEW_TAG }}'
|
||||
working-directory: './release'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
force-skip-tests: '${{ github.event.inputs.force_skip_tests }}'
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: '${{ failure() && github.event.inputs.dry_run == false }}'
|
||||
@@ -246,6 +265,8 @@ jobs:
|
||||
dry-run: '${{ github.event.inputs.dry_run }}'
|
||||
previous-tag: '${{ needs.calculate-versions.outputs.PREVIOUS_STABLE_TAG }}'
|
||||
working-directory: './release'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
force-skip-tests: '${{ github.event.inputs.force_skip_tests }}'
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: '${{ failure() && github.event.inputs.dry_run == false }}'
|
||||
@@ -320,8 +341,7 @@ jobs:
|
||||
branch-name: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
|
||||
pr-title: 'chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}'
|
||||
pr-body: 'Automated version bump to prepare for the next nightly release.'
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
dry-run: '${{ github.event.inputs.dry_run }}'
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
name: 'Release: Rollback change'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rollback_origin:
|
||||
description: 'The package version to rollback FROM and delete (e.g., 0.5.0-preview-2)'
|
||||
required: true
|
||||
type: 'string'
|
||||
rollback_destination:
|
||||
description: 'The package version to rollback TO (e.g., 0.5.0-preview-2). This version must already exist on the npm registry.'
|
||||
required: false
|
||||
type: 'string'
|
||||
channel:
|
||||
description: 'The npm dist-tag to apply to rollback_destination (e.g., latest, preview, nightly). REQUIRED IF rollback_destination is set.'
|
||||
required: false
|
||||
type: 'choice'
|
||||
options:
|
||||
- 'latest'
|
||||
- 'preview'
|
||||
- 'nightly'
|
||||
- 'dev'
|
||||
default: 'dev'
|
||||
ref:
|
||||
description: 'The branch, tag, or SHA to run from.'
|
||||
required: false
|
||||
type: 'string'
|
||||
default: 'main'
|
||||
dry-run:
|
||||
description: 'Whether to run in dry-run mode.'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
change-tags:
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
packages: 'write'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: 'https://wombat-dressing-room.appspot.com'
|
||||
scope: '@google'
|
||||
|
||||
- name: 'Get Origin Version Tag'
|
||||
id: 'origin_tag'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
TAG_VALUE="v${{ github.event.inputs.rollback_origin }}"
|
||||
echo "ORIGIN_TAG=$TAG_VALUE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Get Origin Commit Hash'
|
||||
id: 'origin_hash'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
echo "ORIGIN_HASH=$(git rev-parse ${{ steps.origin_tag.outputs.ORIGIN_TAG }})" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Change tag'
|
||||
if: "${{ github.event.inputs.rollback_destination != '' }}"
|
||||
uses: './.github/actions/tag-npm-release'
|
||||
with:
|
||||
channel: '${{ github.event.inputs.channel }}'
|
||||
version: '${{ github.event.inputs.rollback_destination }}'
|
||||
dry-run: '${{ github.event.inputs.dry-run }}'
|
||||
wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}'
|
||||
wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}'
|
||||
|
||||
- name: 'Deprecate Cli Npm Package'
|
||||
if: "${{ github.event.inputs.dry-run == 'false' }}"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: '${{ secrets.WOMBAT_TOKEN_CLI }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm deprecate @google/gemini-cli@${{ github.event.inputs.rollback_origin }} "This version has been rolled back."
|
||||
|
||||
- name: 'Deprecate Core Npm Package'
|
||||
if: "${{ github.event.inputs.dry-run == 'false' }}"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: '${{ secrets.WOMBAT_TOKEN_CORE }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm deprecate @google/gemini-core@${{ github.event.inputs.rollback_origin }} "This version has been rolled back."
|
||||
|
||||
- name: 'Delete Github Release'
|
||||
if: "${{ github.event.inputs.dry-run == 'false' }}"
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
gh release delete '${{ steps.origin_tag.outputs.ORIGIN_TAG }}' --yes
|
||||
|
||||
- name: 'Verify Origin Release Deletion'
|
||||
if: "${{ github.event.inputs.dry-run == 'false' }}"
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
TARGET_TAG="${{ steps.origin_tag.outputs.ORIGIN_TAG }}"
|
||||
RELEASE_TAG=$(gh release view "$TARGET_TAG" --json tagName --jq .tagName)
|
||||
if [ "$RELEASE_TAG" = "$TARGET_TAG" ]; then
|
||||
echo '❌ Failed to delete release with tag ${{ steps.origin_tag.outputs.ORIGIN_TAG }}'
|
||||
echo '❌ This means the release was not deleted, and the workflow should fail.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Add Rollback Tag'
|
||||
id: 'rollback_tag'
|
||||
if: "${{ github.event.inputs.dry-run == 'false' }}"
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
ROLLBACK_TAG_NAME="${{ steps.origin_tag.outputs.ORIGIN_TAG }}-rollback"
|
||||
echo "ROLLBACK_TAG=$ROLLBACK_TAG_NAME" >> "$GITHUB_OUTPUT"
|
||||
git tag "$ROLLBACK_TAG_NAME" "${{ steps.origin_hash.outputs.ORIGIN_HASH }}"
|
||||
git push origin --tags
|
||||
|
||||
- name: 'Verify Rollback Tag Added'
|
||||
if: "${{ github.event.inputs.dry-run == 'false' }}"
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
TARGET_TAG="${{ steps.rollback_tag.outputs.ROLLBACK_TAG }}"
|
||||
TARGET_HASH="${{ steps.origin_hash.outputs.ORIGIN_HASH }}"
|
||||
ROLLBACK_COMMIT=$(git rev-parse -q --verify "$TARGET_TAG")
|
||||
if [ "$ROLLBACK_COMMIT" != "$TARGET_HASH" ]; then
|
||||
echo '❌ Failed to add tag $TARGET_TAG to commit $TARGET_HASH'
|
||||
echo '❌ This means the tag was not added, and the workflow should fail.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Log Dry run'
|
||||
if: "${{ github.event.inputs.dry-run == 'true' }}"
|
||||
shell: 'bash'
|
||||
run: |
|
||||
echo "
|
||||
Inputs:
|
||||
- rollback_origin: '${{ github.event.inputs.rollback_origin }}'
|
||||
- rollback_destination: '${{ github.event.inputs.rollback_destination }}'
|
||||
- channel: '${{ github.event.inputs.channel }}'
|
||||
- ref: '${{ github.event.inputs.ref }}'
|
||||
|
||||
Outputs:
|
||||
- ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}'
|
||||
- ORIGIN_HASH: '${{ steps.origin_hash.outputs.ORIGIN_HASH }}'
|
||||
- ROLLBACK_TAG: '${{ steps.rollback_tag.outputs.ROLLBACK_TAG }}'
|
||||
|
||||
Would have npm deprecate @google/gemini-cli@${{ github.event.inputs.rollback_origin }} and @google/gemini-core@${{ github.event.inputs.rollback_origin }}
|
||||
Would have deleted the github release with tag ${{ steps.origin_tag.outputs.ORIGIN_TAG }}
|
||||
Would have added tag ${{ steps.origin_tag.outputs.ORIGIN_TAG }}-rollback to ${{ steps.origin_hash.outputs.ORIGIN_HASH }}
|
||||
"
|
||||
@@ -17,6 +17,10 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
packages: 'write'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
@@ -26,8 +30,20 @@ jobs:
|
||||
- name: 'Push'
|
||||
uses: './.github/actions/push-sandbox'
|
||||
with:
|
||||
dockerhub-username: '${{ secrets.DOCKER_SERVICE_ACCOUNT_NAME }}'
|
||||
dockerhub-token: '${{ secrets.DOCKER_SERVICE_ACCOUNT_KEY }}'
|
||||
github-actor: '${{ github.actor }}'
|
||||
github-secret: '${{ secrets.GITHUB_TOKEN }}'
|
||||
github-sha: '${{ github.event.inputs.ref || github.sha }}'
|
||||
github-sha: '${{ github.sha }}'
|
||||
github-ref-name: '${{github.event.inputs.ref}}'
|
||||
dry-run: '${{ github.event.inputs.dry-run }}'
|
||||
- name: 'Create Issue on Failure'
|
||||
if: '${{ failure() && github.event.inputs.dry-run == false }}'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |
|
||||
gh issue create \
|
||||
--title 'Sandbox Release Failed on $(date +'%Y-%m-%d')' \
|
||||
--body 'The sandbox-release workflow failed. See the full run for details: ${DETAILS_URL}' \
|
||||
--label 'kind/bug,release-failure,priority/p0'
|
||||
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
- name: 'Verify release'
|
||||
uses: './.github/actions/verify-release'
|
||||
with:
|
||||
npm-package: '${github.event.inputs.npm-package}'
|
||||
expected-version: '${github.event.inputs.version}'
|
||||
ref: '${github.event.inputs.ref}'
|
||||
npm-package: '${{github.event.inputs.npm-package}}'
|
||||
expected-version: '${{github.event.inputs.version}}'
|
||||
ref: '${{github.event.inputs.ref}}'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# Dependency directory
|
||||
node_modules
|
||||
bower_components
|
||||
package-lock.json
|
||||
|
||||
# Editors
|
||||
.idea
|
||||
@@ -49,3 +50,6 @@ packages/vscode-ide-companion/*.vsix
|
||||
|
||||
# GHA credentials
|
||||
gha-creds-*.json
|
||||
|
||||
# Log files
|
||||
patch_output.log
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
npm run pre-commit || {
|
||||
echo ''
|
||||
echo '===================================================='
|
||||
echo 'pre-commit checks failed. in case of emergency, run:'
|
||||
echo ''
|
||||
echo 'git commit --no-verify'
|
||||
echo '===================================================='
|
||||
exit 1
|
||||
}
|
||||
Vendored
+2
-1
@@ -12,5 +12,6 @@
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true
|
||||
}
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ To start the Gemini CLI from the source code (after building), run the following
|
||||
npm start
|
||||
```
|
||||
|
||||
If you'd like to run the source build outside of the gemini-cli folder you can utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini`
|
||||
If you'd like to run the source build outside of the gemini-cli folder, you can utilize `npm link path/to/gemini-cli/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias gemini="node path/to/gemini-cli/packages/cli"` to run with `gemini`
|
||||
|
||||
### Running Tests
|
||||
|
||||
|
||||
+4
-4
@@ -40,11 +40,11 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
USER node
|
||||
|
||||
# install gemini-cli and clean up
|
||||
COPY packages/cli/dist/google-gemini-cli-*.tgz /usr/local/share/npm-global/gemini-cli.tgz
|
||||
COPY packages/core/dist/google-gemini-cli-core-*.tgz /usr/local/share/npm-global/gemini-core.tgz
|
||||
RUN npm install -g /usr/local/share/npm-global/gemini-cli.tgz /usr/local/share/npm-global/gemini-core.tgz \
|
||||
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
RUN npm install -g /tmp/gemini-cli.tgz /tmp/gemini-core.tgz \
|
||||
&& npm cache clean --force \
|
||||
&& rm -f /usr/local/share/npm-global/gemini-{cli,core}.tgz
|
||||
&& rm -f /tmp/gemini-{cli,core}.tgz
|
||||
|
||||
# default entrypoint when none specified
|
||||
CMD ["gemini"]
|
||||
|
||||
@@ -109,14 +109,20 @@ Choose the authentication method that best fits your needs:
|
||||
|
||||
### Option 1: Login with Google (OAuth login using your Google Account)
|
||||
|
||||
**✨ Best for:** Individual developers as well as anyone who has a Gemini Code Assist License. (see [quota limits and terms of service](https://cloud.google.com/gemini/docs/quotas) for details)
|
||||
**✨ Best for:**
|
||||
|
||||
- Individual developers.
|
||||
- Google AI Pro and AI Ultra subscribers.
|
||||
- Anyone who has a Gemini Code Assist license.
|
||||
|
||||
_See [quota limits and terms of service](https://cloud.google.com/gemini/docs/quotas) for details._
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- **Free tier**: 60 requests/min and 1,000 requests/day
|
||||
- **Gemini 2.5 Pro** with 1M token context window
|
||||
- **Free tier** with 60 requests/min and 1,000 requests/day
|
||||
- **Gemini 2.5 Pro and Flash** with 1M token context window
|
||||
- **No API key management** - just sign in with your Google account
|
||||
- **Automatic updates** to latest models
|
||||
- **Automatic updates** to our latest models
|
||||
|
||||
#### Start Gemini CLI, then choose _Login with Google_ and follow the browser authentication flow when prompted
|
||||
|
||||
|
||||
+26
-21
@@ -1,30 +1,35 @@
|
||||
# Authentication Setup
|
||||
|
||||
The Gemini CLI requires you to authenticate with Google's AI services. On initial startup you'll need to configure **one** of the following authentication methods:
|
||||
Gemini CLI requires you to authenticate with Google's AI services. On initial startup you'll need to configure **one** of the following authentication methods:
|
||||
|
||||
1. **Login with Google (Gemini Code Assist):**
|
||||
- Use this option to log in with your Google account.
|
||||
- During initial startup, Gemini CLI will direct you to a webpage for authentication. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
|
||||
- Note that the web login must be done in a browser that can communicate with the machine Gemini CLI is being run from. (Specifically, the browser will be redirected to a localhost url that Gemini CLI will be listening on).
|
||||
- <a id="workspace-gca">Users may have to specify a GOOGLE_CLOUD_PROJECT if:</a>
|
||||
1. You have a Google Workspace account. Google Workspace is a paid service for businesses and organizations that provides a suite of productivity tools, including a custom email domain (e.g. your-name@your-company.com), enhanced security features, and administrative controls. These accounts are often managed by an employer or school.
|
||||
1. You have received a Gemini Code Assist license through the [Google Developer Program](https://developers.google.com/program/plans-and-pricing) (including qualified Google Developer Experts)
|
||||
1. You have been assigned a license to a current Gemini Code Assist standard or enterprise subscription.
|
||||
1. You are using the product outside the [supported regions](https://developers.google.com/gemini-code-assist/resources/available-locations) for free individual usage.
|
||||
1. You are a Google account holder under the age of 18
|
||||
- If you fall into one of these categories, you must first configure a Google Cloud Project ID to use, [enable the Gemini for Cloud API](https://cloud.google.com/gemini/docs/discover/set-up-gemini#enable-api) and [configure access permissions](https://cloud.google.com/gemini/docs/discover/set-up-gemini#grant-iam).
|
||||
1. **Login with Google**
|
||||
1. **Google AI Pro and AI Ultra subscribers**
|
||||
- Use this option to log in with your Google account that you use with Google AI Pro and Ultra.
|
||||
- During initial startup, Gemini CLI will direct you to a webpage for authentication. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
|
||||
- Note that the web login must be done in a browser that can communicate with the machine Gemini CLI is being run from. (Specifically, the browser will be redirected to a localhost URL that Gemini CLI will be listening on.)
|
||||
2. **Gemini Code Assist:**
|
||||
- Use this option to log in with your Google account.
|
||||
- During initial startup, Gemini CLI will direct you to a webpage for authentication. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
|
||||
- Note that the web login must be done in a browser that can communicate with the machine Gemini CLI is being run from. (Specifically, the browser will be redirected to a localhost url that Gemini CLI will be listening on.)
|
||||
- <a id="workspace-gca">Users may have to specify a GOOGLE_CLOUD_PROJECT if:</a>
|
||||
1. You have a Google Workspace account. Google Workspace is a paid service for businesses and organizations that provides a suite of productivity tools, including a custom email domain (e.g. your-name@your-company.com), enhanced security features, and administrative controls. These accounts are often managed by an employer or school.
|
||||
1. You have received a Gemini Code Assist license through the [Google Developer Program](https://developers.google.com/program/plans-and-pricing) (including qualified Google Developer Experts).
|
||||
1. You have been assigned a license to a current Gemini Code Assist standard or enterprise subscription.
|
||||
1. You are using the product outside the [supported regions](https://developers.google.com/gemini-code-assist/resources/available-locations) for free individual usage.
|
||||
1. You are a Google account holder under the age of 18.
|
||||
- If you fall into one of these categories, you must first configure a Google Cloud Project ID to use, [enable the Gemini for Cloud API](https://cloud.google.com/gemini/docs/discover/set-up-gemini#enable-api) and [configure access permissions](https://cloud.google.com/gemini/docs/discover/set-up-gemini#grant-iam).
|
||||
|
||||
You can temporarily set the environment variable in your current shell session using the following command:
|
||||
You can temporarily set the environment variable in your current shell session using the following command:
|
||||
|
||||
```bash
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
```
|
||||
- For repeated use, you can add the environment variable to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following command adds the environment variable to a `~/.bashrc` file:
|
||||
```bash
|
||||
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
|
||||
```
|
||||
- For repeated use, you can add the environment variable to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following command adds the environment variable to a `~/.bashrc` file:
|
||||
|
||||
```bash
|
||||
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
```bash
|
||||
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
2. **<a id="gemini-api-key"></a>Gemini API key:**
|
||||
- Obtain your API key from Google AI Studio: [https://aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)
|
||||
|
||||
@@ -125,6 +125,10 @@ Settings are organized into categories. All settings should be placed within the
|
||||
- **Description:** Disable loading phrases for accessibility.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.customWittyPhrases`** (array of strings):
|
||||
- **Description:** A list of custom phrases to display during loading states. When provided, the CLI will cycle through these phrases instead of the default ones.
|
||||
- **Default:** `[]`
|
||||
|
||||
#### `ide`
|
||||
|
||||
- **`ide.enabled`** (boolean):
|
||||
@@ -309,6 +313,7 @@ Configures logging and metrics collection for Gemini CLI. For more information,
|
||||
- **`otlpProtocol`** (string): The protocol for the OTLP Exporter (`grpc` or `http`).
|
||||
- **`logPrompts`** (boolean): Whether or not to include the content of user prompts in the logs.
|
||||
- **`outfile`** (string): The file to write telemetry to when `target` is `local`.
|
||||
- **`useCollector`** (boolean): Whether to use an external OTLP collector.
|
||||
|
||||
### Example `settings.json`
|
||||
|
||||
@@ -323,7 +328,11 @@ Here is an example of a `settings.json` file with the nested structure, new as o
|
||||
"ui": {
|
||||
"theme": "GitHub",
|
||||
"hideBanner": true,
|
||||
"hideTips": false
|
||||
"hideTips": false,
|
||||
"customWittyPhrases": [
|
||||
"You forget a thousand things every day. Make sure this is one of ’em",
|
||||
"Connecting to AGI"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"sandbox": "docker",
|
||||
@@ -417,6 +426,27 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
|
||||
- **`OTLP_GOOGLE_CLOUD_PROJECT`**:
|
||||
- Your Google Cloud Project ID for Telemetry in Google Cloud
|
||||
- Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"`.
|
||||
- **`GEMINI_TELEMETRY_ENABLED`**:
|
||||
- Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it.
|
||||
- Overrides the `telemetry.enabled` setting.
|
||||
- **`GEMINI_TELEMETRY_TARGET`**:
|
||||
- Sets the telemetry target (`local` or `gcp`).
|
||||
- Overrides the `telemetry.target` setting.
|
||||
- **`GEMINI_TELEMETRY_OTLP_ENDPOINT`**:
|
||||
- Sets the OTLP endpoint for telemetry.
|
||||
- Overrides the `telemetry.otlpEndpoint` setting.
|
||||
- **`GEMINI_TELEMETRY_OTLP_PROTOCOL`**:
|
||||
- Sets the OTLP protocol (`grpc` or `http`).
|
||||
- Overrides the `telemetry.otlpProtocol` setting.
|
||||
- **`GEMINI_TELEMETRY_LOG_PROMPTS`**:
|
||||
- Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it.
|
||||
- Overrides the `telemetry.logPrompts` setting.
|
||||
- **`GEMINI_TELEMETRY_OUTFILE`**:
|
||||
- Sets the file path to write telemetry to when the target is `local`.
|
||||
- Overrides the `telemetry.outfile` setting.
|
||||
- **`GEMINI_TELEMETRY_USE_COLLECTOR`**:
|
||||
- Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it.
|
||||
- Overrides the `telemetry.useCollector` setting.
|
||||
- **`GOOGLE_CLOUD_LOCATION`**:
|
||||
- Your Google Cloud Project Location (e.g., us-central1).
|
||||
- Required for using Vertex AI in non express mode.
|
||||
|
||||
@@ -147,7 +147,7 @@ Processes import statements in GEMINI.md content.
|
||||
- `debugMode` (boolean, optional): Whether to enable debug logging (default: false)
|
||||
- `importState` (ImportState, optional): State tracking for circular import prevention
|
||||
|
||||
**Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree
|
||||
**Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree
|
||||
|
||||
### `ProcessImportsResult`
|
||||
|
||||
@@ -187,7 +187,7 @@ Finds the project root by searching for a `.git` directory upwards from the give
|
||||
|
||||
- `startDir` (string): The directory to start searching from
|
||||
|
||||
**Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found)
|
||||
**Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found)
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
+4
-4
@@ -14,12 +14,12 @@ Note that all of these commands will only be reflected in active CLI sessions on
|
||||
|
||||
### Installing an extension
|
||||
|
||||
You can install an extension using `gemini extensions install` with either a GitHub URL source or `--path=some/local/path`.
|
||||
You can install an extension using `gemini extensions install` with either a GitHub URL or a local path`.
|
||||
|
||||
Note that we create a copy of the installed extension, so you will need to run `gemini extensions update` to pull in changes from both locally-defined extensions and those on GitHub.
|
||||
|
||||
```
|
||||
gemini extensions install https://github.com/google-gemini/gemini-cli-security
|
||||
gemini extensions install https://github.com/gemini-cli-extensions/security
|
||||
```
|
||||
|
||||
This will install the Gemini CLI Security extension, which offers support for a `/security:analyze` command.
|
||||
@@ -65,7 +65,7 @@ We offer several example extensions `context`, `custom-commands`, `exclude-tools
|
||||
To copy one of these examples into a development directory using the type of your choosing, run:
|
||||
|
||||
```
|
||||
gemini extensions new --path=path/to/directory --type=custom-commands
|
||||
gemini extensions new path/to/directory custom-commands
|
||||
```
|
||||
|
||||
### Link a local extension
|
||||
@@ -104,7 +104,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name.
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase or numbers and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for `trust`.
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Getting Started with Gemini CLI Extensions
|
||||
|
||||
This guide will walk you through creating your first Gemini CLI extension. You'll learn how to set up a new extension, add a custom tool via an MCP server, create a custom command, and provide context to the model with a `GEMINI.md` file.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start, make sure you have the Gemini CLI installed and a basic understanding of Node.js and TypeScript.
|
||||
|
||||
## Step 1: Create a New Extension
|
||||
|
||||
The easiest way to start is by using one of the built-in templates. We'll use the `mcp-server` example as our foundation.
|
||||
|
||||
Run the following command to create a new directory called `my-first-extension` with the template files:
|
||||
|
||||
```bash
|
||||
gemini extensions new my-first-extension mcp-server
|
||||
```
|
||||
|
||||
This will create a new directory with the following structure:
|
||||
|
||||
```
|
||||
my-first-extension/
|
||||
├── example.ts
|
||||
├── gemini-extension.json
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Step 2: Understand the Extension Files
|
||||
|
||||
Let's look at the key files in your new extension.
|
||||
|
||||
### `gemini-extension.json`
|
||||
|
||||
This is the manifest file for your extension. It tells Gemini CLI how to load and use your extension.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-first-extension",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The unique name for your extension.
|
||||
- `version`: The version of your extension.
|
||||
- `mcpServers`: This section defines one or more Model Context Protocol (MCP) servers. MCP servers are how you can add new tools for the model to use.
|
||||
- `command`, `args`, `cwd`: These fields specify how to start your server. Notice the use of the `${extensionPath}` variable, which Gemini CLI replaces with the absolute path to your extension's installation directory. This allows your extension to work regardless of where it's installed.
|
||||
|
||||
### `example.ts`
|
||||
|
||||
This file contains the source code for your MCP server. It's a simple Node.js server that uses the `@modelcontextprotocol/sdk`.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'prompt-server',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Registers a new tool named 'fetch_posts'
|
||||
server.registerTool(
|
||||
'fetch_posts',
|
||||
{
|
||||
description: 'Fetches a list of posts from a public API.',
|
||||
inputSchema: z.object({}).shape,
|
||||
},
|
||||
async () => {
|
||||
const apiResponse = await fetch(
|
||||
'https://jsonplaceholder.typicode.com/posts',
|
||||
);
|
||||
const posts = await apiResponse.json();
|
||||
const response = { posts: posts.slice(0, 5) };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ... (prompt registration omitted for brevity)
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
```
|
||||
|
||||
This server defines a single tool called `fetch_posts` that fetches data from a public API.
|
||||
|
||||
### `package.json` and `tsconfig.json`
|
||||
|
||||
These are standard configuration files for a TypeScript project. The `package.json` file defines dependencies and a `build` script, and `tsconfig.json` configures the TypeScript compiler.
|
||||
|
||||
## Step 3: Build and Link Your Extension
|
||||
|
||||
Before you can use the extension, you need to compile the TypeScript code and link the extension to your Gemini CLI installation for local development.
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
cd my-first-extension
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Build the server:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This will compile `example.ts` into `dist/example.js`, which is the file referenced in your `gemini-extension.json`.
|
||||
|
||||
3. **Link the extension:**
|
||||
|
||||
The `link` command creates a symbolic link from the Gemini CLI extensions directory to your development directory. This means any changes you make will be reflected immediately without needing to reinstall.
|
||||
|
||||
```bash
|
||||
gemini extensions link .
|
||||
```
|
||||
|
||||
Now, restart your Gemini CLI session. The new `fetch_posts` tool will be available. You can test it by asking: "fetch posts".
|
||||
|
||||
## Step 4: Add a Custom Command
|
||||
|
||||
Custom commands provide a way to create shortcuts for complex prompts. Let's add a command that searches for a pattern in your code.
|
||||
|
||||
1. Create a `commands` directory and a subdirectory for your command group:
|
||||
|
||||
```bash
|
||||
mkdir -p commands/fs
|
||||
```
|
||||
|
||||
2. Create a file named `commands/fs/grep-code.toml`:
|
||||
|
||||
```toml
|
||||
prompt = """
|
||||
Please summarize the findings for the pattern `{{args}}`.
|
||||
|
||||
Search Results:
|
||||
!{grep -r {{args}} .}
|
||||
"""
|
||||
```
|
||||
|
||||
This command, `/fs:grep-code`, will take an argument, run the `grep` shell command with it, and pipe the results into a prompt for summarization.
|
||||
|
||||
After saving the file, restart the Gemini CLI. You can now run `/fs:grep-code "some pattern"` to use your new command.
|
||||
|
||||
## Step 5: Add a Custom `GEMINI.md`
|
||||
|
||||
You can provide persistent context to the model by adding a `GEMINI.md` file to your extension. This is useful for giving the model instructions on how to behave or information about your extension's tools. Note that you may not always need this for extensions built to expose commands and prompts.
|
||||
|
||||
1. Create a file named `GEMINI.md` in the root of your extension directory:
|
||||
|
||||
```markdown
|
||||
# My First Extension Instructions
|
||||
|
||||
You are an expert developer assistant. When the user asks you to fetch posts, use the `fetch_posts` tool. Be concise in your responses.
|
||||
```
|
||||
|
||||
2. Update your `gemini-extension.json` to tell the CLI to load this file:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-first-extension",
|
||||
"version": "1.0.0",
|
||||
"contextFileName": "GEMINI.md",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the CLI again. The model will now have the context from your `GEMINI.md` file in every session where the extension is active.
|
||||
|
||||
## Step 6: Releasing Your Extension
|
||||
|
||||
Once you are happy with your extension, you can share it with others. The two primary ways of releasing extensions are via a Git repository or through GitHub Releases. Using a public Git repository is the simplest method.
|
||||
|
||||
For detailed instructions on both methods, please refer to the [Extension Releasing Guide](extension-releasing.md).
|
||||
|
||||
## Conclusion
|
||||
|
||||
You've successfully created a Gemini CLI extension! You learned how to:
|
||||
|
||||
- Bootstrap a new extension from a template.
|
||||
- Add custom tools with an MCP server.
|
||||
- Create convenient custom commands.
|
||||
- Provide persistent context to the model.
|
||||
- Link your extension for local development.
|
||||
|
||||
From here, you can explore more advanced features and build powerful new capabilities into the Gemini CLI.
|
||||
+5
-5
@@ -244,7 +244,7 @@ Key command-line options for headless usage:
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `gemini -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode | `gemini -p "query" --approval-mode auto_edit` |
|
||||
|
||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./configuration.md).
|
||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md).
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -317,7 +317,7 @@ tail -5 usage.log
|
||||
|
||||
## Resources
|
||||
|
||||
- [CLI Configuration](./configuration.md) - Complete configuration guide
|
||||
- [Authentication](./authentication.md) - Setup authentication
|
||||
- [Commands](./commands.md) - Interactive commands reference
|
||||
- [Tutorials](./tutorials.md) - Step-by-step automation guides
|
||||
- [CLI Configuration](./cli/configuration.md) - Complete configuration guide
|
||||
- [Authentication](./cli/authentication.md) - Setup authentication
|
||||
- [Commands](./cli/commands.md) - Interactive commands reference
|
||||
- [Tutorials](./cli/tutorials.md) - Step-by-step automation guides
|
||||
|
||||
+37
-62
@@ -1,16 +1,16 @@
|
||||
# Gemini CLI Companion Extension: Interface Specification
|
||||
# Gemini CLI Companion Plugin: Interface Specification
|
||||
|
||||
> Last Updated: September 15, 2025
|
||||
|
||||
This document defines the contract for building a companion extension to enable Gemini CLI's IDE mode. For VS Code, these features (native diffing, context awareness) are provided by the official extension ([marketplace](https://marketplace.visualstudio.com/items?itemName=Google.gemini-cli-vscode-ide-companion)). This specification is for contributors who wish to bring similar functionality to other editors like JetBrains IDEs, Sublime Text, etc.
|
||||
This document defines the contract for building a companion plugin to enable Gemini CLI's IDE mode. For VS Code, these features (native diffing, context awareness) are provided by the official extension ([marketplace](https://marketplace.visualstudio.com/items?itemName=Google.gemini-cli-vscode-ide-companion)). This specification is for contributors who wish to bring similar functionality to other editors like JetBrains IDEs, Sublime Text, etc.
|
||||
|
||||
## I. The Communication Interface
|
||||
|
||||
Gemini CLI and the IDE extension communicate through a local communication channel.
|
||||
Gemini CLI and the IDE plugin communicate through a local communication channel.
|
||||
|
||||
### 1. Transport Layer: MCP over HTTP
|
||||
|
||||
The extension **MUST** run a local HTTP server that implements the **Model Context Protocol (MCP)**.
|
||||
The plugin **MUST** run a local HTTP server that implements the **Model Context Protocol (MCP)**.
|
||||
|
||||
- **Protocol:** The server must be a valid MCP server. We recommend using an existing MCP SDK for your language of choice if available.
|
||||
- **Endpoint:** The server should expose a single endpoint (e.g., `/mcp`) for all MCP communication.
|
||||
@@ -18,44 +18,44 @@ The extension **MUST** run a local HTTP server that implements the **Model Conte
|
||||
|
||||
### 2. Discovery Mechanism: The Port File
|
||||
|
||||
For Gemini CLI to connect, it needs to discover which IDE instance it's running in and what port your server is using. The extension **MUST** facilitate this by creating a "discovery file."
|
||||
For Gemini CLI to connect, it needs to discover which IDE instance it's running in and what port your server is using. The plugin **MUST** facilitate this by creating a "discovery file."
|
||||
|
||||
- **How the CLI Finds the File:** The CLI determines the Process ID (PID) of the IDE it's running in by traversing the process tree. It then looks for a discovery file that contains this PID in its name.
|
||||
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/gemini/ide/`. Your extension must create this directory if it doesn't exist.
|
||||
- **File Location:** The file must be created in a specific directory: `os.tmpdir()/gemini/ide/`. Your plugin must create this directory if it doesn't exist.
|
||||
- **File Naming Convention:** The filename is critical and **MUST** follow the pattern:
|
||||
`gemini-ide-server-${PID}-${PORT}.json`
|
||||
- `${PID}`: The process ID of the parent IDE process. Your extension must determine this PID and include it in the filename.
|
||||
- `${PID}`: The process ID of the parent IDE process. Your plugin must determine this PID and include it in the filename.
|
||||
- `${PORT}`: The port your MCP server is listening on.
|
||||
- **File Content & Workspace Validation:** The file **MUST** contain a JSON object with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 12345,
|
||||
"workspacePath": "/path/to/project1:/path/to/project2"
|
||||
"workspacePath": "/path/to/project1:/path/to/project2",
|
||||
"authToken": "a-very-secret-token",
|
||||
"ideInfo": {
|
||||
"name": "vscode",
|
||||
"displayName": "VS Code"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `port` (number): The port of the MCP server.
|
||||
- `workspacePath` (string): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your extension **MUST** provide the correct, absolute path(s) to the root of the open workspace(s).
|
||||
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your extension **SHOULD** both create the discovery file and set the `GEMINI_CLI_IDE_SERVER_PORT` and `GEMINI_CLI_IDE_WORKSPACE_PATH` environment variables in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variables are crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `GEMINI_CLI_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
|
||||
- For prototyping, you may opt to _only_ set the environment variables. However, this is not a robust solution for a production extension, as environment variables may not be reliably set in all terminal sessions (e.g., restored terminals), which can lead to connection failures.
|
||||
- **Authentication:** To secure the connection, the extension **SHOULD** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in all requests to the MCP server.
|
||||
- **Token Generation:** The extension should generate a random string to be used as a bearer token.
|
||||
- **Discovery File Content:** The `authToken` field must be added to the JSON object in the discovery file:
|
||||
```json
|
||||
{
|
||||
"port": 12345,
|
||||
"workspacePath": "/path/to/project",
|
||||
"authToken": "a-very-secret-token"
|
||||
}
|
||||
```
|
||||
- **Request Authorization:** The CLI will read the `authToken` from the file and include it in the `Authorization` header for all HTTP requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
|
||||
- `port` (number, required): The port of the MCP server.
|
||||
- `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your plugin **MUST** provide the correct, absolute path(s) to the root of the open workspace(s).
|
||||
- `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
|
||||
- `ideInfo` (object, required): Information about the IDE.
|
||||
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`).
|
||||
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
|
||||
|
||||
- **Authentication:** To secure the connection, the plugin **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized.
|
||||
- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your plugin **SHOULD** both create the discovery file and set the `GEMINI_CLI_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `GEMINI_CLI_IDE_SERVER_PORT` variable to identify and connect to the correct window's server.
|
||||
|
||||
## II. The Context Interface
|
||||
|
||||
To enable context awareness, the extension **MAY** provide the CLI with real-time information about the user's activity in the IDE.
|
||||
To enable context awareness, the plugin **MAY** provide the CLI with real-time information about the user's activity in the IDE.
|
||||
|
||||
### `ide/contextUpdate` Notification
|
||||
|
||||
The extension **MAY** send an `ide/contextUpdate` [notification](https://modelcontextprotocol.io/specification/2025-06-18/basic/index#notifications) to the CLI whenever the user's context changes.
|
||||
The plugin **MAY** send an `ide/contextUpdate` [notification](https://modelcontextprotocol.io/specification/2025-06-18/basic/index#notifications) to the CLI whenever the user's context changes.
|
||||
|
||||
- **Triggering Events:** This notification should be sent (with a recommended debounce of 50ms) when:
|
||||
- A file is opened, closed, or focused.
|
||||
@@ -94,19 +94,19 @@ The extension **MAY** send an `ide/contextUpdate` [notification](https://modelco
|
||||
|
||||
After receiving the `IdeContext` object, the CLI performs several normalization and truncation steps before sending the information to the model.
|
||||
|
||||
- **File Ordering:** The CLI uses the `timestamp` field to determine the most recently used files. It sorts the `openFiles` list based on this value. Therefore, your extension **MUST** provide an accurate Unix timestamp for when a file was last focused.
|
||||
- **Active File:** The CLI considers only the most recent file (after sorting) to be the "active" file. It will ignore the `isActive` flag on all other files and clear their `cursor` and `selectedText` fields. Your extension should focus on setting `isActive: true` and providing cursor/selection details only for the currently focused file.
|
||||
- **File Ordering:** The CLI uses the `timestamp` field to determine the most recently used files. It sorts the `openFiles` list based on this value. Therefore, your plugin **MUST** provide an accurate Unix timestamp for when a file was last focused.
|
||||
- **Active File:** The CLI considers only the most recent file (after sorting) to be the "active" file. It will ignore the `isActive` flag on all other files and clear their `cursor` and `selectedText` fields. Your plugin should focus on setting `isActive: true` and providing cursor/selection details only for the currently focused file.
|
||||
- **Truncation:** To manage token limits, the CLI truncates both the file list (to 10 files) and the `selectedText` (to 16KB).
|
||||
|
||||
While the CLI handles the final truncation, it is highly recommended that your extension also limits the amount of context it sends.
|
||||
While the CLI handles the final truncation, it is highly recommended that your plugin also limits the amount of context it sends.
|
||||
|
||||
## III. The Diffing Interface
|
||||
|
||||
To enable interactive code modifications, the extension **MAY** expose a diffing interface. This allows the CLI to request that the IDE open a diff view, showing proposed changes to a file. The user can then review, edit, and ultimately accept or reject these changes directly within the IDE.
|
||||
To enable interactive code modifications, the plugin **MAY** expose a diffing interface. This allows the CLI to request that the IDE open a diff view, showing proposed changes to a file. The user can then review, edit, and ultimately accept or reject these changes directly within the IDE.
|
||||
|
||||
### `openDiff` Tool
|
||||
|
||||
The extension **MUST** register an `openDiff` tool on its MCP server.
|
||||
The plugin **MUST** register an `openDiff` tool on its MCP server.
|
||||
|
||||
- **Description:** This tool instructs the IDE to open a modifiable diff view for a specific file.
|
||||
- **Request (`OpenDiffRequest`):** The tool is invoked via a `tools/call` request. The `arguments` field within the request's `params` **MUST** be an `OpenDiffRequest` object.
|
||||
@@ -128,7 +128,7 @@ The extension **MUST** register an `openDiff` tool on its MCP server.
|
||||
|
||||
### `closeDiff` Tool
|
||||
|
||||
The extension **MUST** register a `closeDiff` tool on its MCP server.
|
||||
The plugin **MUST** register a `closeDiff` tool on its MCP server.
|
||||
|
||||
- **Description:** This tool instructs the IDE to close an open diff view for a specific file.
|
||||
- **Request (`CloseDiffRequest`):** The tool is invoked via a `tools/call` request. The `arguments` field within the request's `params` **MUST** be an `CloseDiffRequest` object.
|
||||
@@ -146,7 +146,7 @@ The extension **MUST** register a `closeDiff` tool on its MCP server.
|
||||
|
||||
### `ide/diffAccepted` Notification
|
||||
|
||||
When the user accepts the changes in a diff view (e.g., by clicking an "Apply" or "Save" button), the extension **MUST** send an `ide/diffAccepted` notification to the CLI.
|
||||
When the user accepts the changes in a diff view (e.g., by clicking an "Apply" or "Save" button), the plugin **MUST** send an `ide/diffAccepted` notification to the CLI.
|
||||
|
||||
- **Payload:** The notification parameters **MUST** include the file path and the final content of the file. The content may differ from the original `newContent` if the user made manual edits in the diff view.
|
||||
|
||||
@@ -161,7 +161,7 @@ When the user accepts the changes in a diff view (e.g., by clicking an "Apply" o
|
||||
|
||||
### `ide/diffRejected` Notification
|
||||
|
||||
When the user rejects the changes (e.g., by closing the diff view without accepting), the extension **MUST** send an `ide/diffRejected` notification to the CLI.
|
||||
When the user rejects the changes (e.g., by closing the diff view without accepting), the plugin **MUST** send an `ide/diffRejected` notification to the CLI.
|
||||
|
||||
- **Payload:** The notification parameters **MUST** include the file path of the rejected diff.
|
||||
|
||||
@@ -172,38 +172,13 @@ When the user rejects the changes (e.g., by closing the diff view without accept
|
||||
}
|
||||
```
|
||||
|
||||
## IV. Supporting Additional IDEs
|
||||
## IV. The Lifecycle Interface
|
||||
|
||||
To add support for a new IDE, two main components in the Gemini CLI codebase need to be updated: the detection logic and the installer logic.
|
||||
The plugin **MUST** manage its resources and the discovery file correctly based on the IDE's lifecycle.
|
||||
|
||||
### 1. IDE Detection (`@packages/core/src/ide/detect-ide.ts`)
|
||||
|
||||
// TODO(skeshive): Determine whether we should discover the IDE via the port file
|
||||
|
||||
The CLI must be able to identify when it is running inside a specific IDE's integrated terminal. This is primarily done by checking for unique environment variables. As a fallback, it can also inspect process information (like the command name) to help distinguish between IDEs if a unique environment variable is not available.
|
||||
|
||||
- **Add to `DetectedIde` Enum:** First, add your new IDE to the `DetectedIde` enum.
|
||||
- **Update `detectIdeFromEnv`:** Add a check in this function for an environment variable specific to your IDE (e.g., `if (process.env['MY_IDE_VAR']) { return DetectedIde.MyIde; }`).
|
||||
- **Update `detectIde` (Optional):** If your IDE lacks a unique environment variable, you can add logic to the `detectIde` function to inspect `ideProcessInfo` (e.g., `ideProcessInfo.command`) as a secondary detection mechanism.
|
||||
|
||||
### 2. Extension Installation (`@packages/core/src/ide/ide-installer.ts`)
|
||||
|
||||
The CLI provides a command (`/ide install`) to help users automatically install the companion extension. While optional, implementing an `IdeInstaller` for your IDE is highly recommended to provide a seamless setup experience.
|
||||
|
||||
- **Create an Installer Class:** Create a new class that implements the `IdeInstaller` interface.
|
||||
- **Implement `install()`:** The `install` method should:
|
||||
1. Locate the IDE's command-line executable. The `VsCodeInstaller` provides a good example of searching common installation paths for different operating systems.
|
||||
2. Execute the command to install the extension by its marketplace ID (e.g., `"path/to/my-ide-cli" --install-extension my-publisher.my-extension-id`).
|
||||
3. Return a result object indicating success or failure.
|
||||
- **Update `getIdeInstaller`:** Add a case to the `switch` statement in this factory function to return an instance of your new installer class when your `DetectedIde` enum is matched.
|
||||
|
||||
## V. The Lifecycle Interface
|
||||
|
||||
The extension **MUST** manage its resources and the discovery file correctly based on the IDE's lifecycle.
|
||||
|
||||
- **On Activation (IDE startup/extension enabled):**
|
||||
- **On Activation (IDE startup/plugin enabled):**
|
||||
1. Start the MCP server.
|
||||
2. Create the discovery file.
|
||||
- **On Deactivation (IDE shutdown/extension disabled):**
|
||||
- **On Deactivation (IDE shutdown/plugin disabled):**
|
||||
1. Stop the MCP server.
|
||||
2. Delete the discovery file.
|
||||
|
||||
@@ -21,6 +21,7 @@ This documentation is organized into the following sections:
|
||||
- **[IDE Integration](./ide-integration.md):** Connect the CLI to your editor.
|
||||
- **[IDE Companion Extension Spec](./ide-companion-spec.md):** Spec for building IDE companion extensions.
|
||||
- **[Telemetry](./telemetry.md):** Overview of telemetry in the CLI.
|
||||
- **[Trusted Folders](./trusted-folders.md):** An overview of the Trusted Folders security feature.
|
||||
- **Core Details:** Documentation for `packages/core`.
|
||||
- **[Core Introduction](./core/index.md):** Overview of the core component.
|
||||
- **[Tools API](./core/tools-api.md):** Information on how the core manages and exposes tools.
|
||||
|
||||
@@ -20,7 +20,7 @@ npm run test:e2e
|
||||
|
||||
## Running a specific set of tests
|
||||
|
||||
To run a subset of test files, you can use `npm run <integration test command> <file_name1> ....` where <integration test command> is either `test:e2e` or `test:integration*` and `<file_name>` is any of the `.test.js` files in the `integration-tests/` directory. For example, the following command runs `list_directory.test.js` and `write_file.test.js`:
|
||||
To run a subset of test files, you can use `npm run <integration test command> <file_name1> ....` where <integration test command> is either `test:e2e` or `test:integration*` and `<file_name>` is any of the `.test.js` files in the `integration-tests/` directory. For example, the following command runs `list_directory.test.js` and `write_file.test.js`:
|
||||
|
||||
```bash
|
||||
npm run test:e2e list_directory write_file
|
||||
|
||||
@@ -73,7 +73,7 @@ This is a fallback workflow to ensure that no issue gets missed by the triage pr
|
||||
|
||||
This workflow handles the process of packaging and publishing new versions of the Gemini CLI.
|
||||
|
||||
- **Workflow File**: `.github/workflows/release.yml`
|
||||
- **Workflow File**: `.github/workflows/release-manual.yml`
|
||||
- **When it runs**: On a daily schedule for "nightly" releases, and manually for official patch/minor releases.
|
||||
- **What it does**:
|
||||
- Automatically builds the project, bumps the version numbers, and publishes the packages to npm.
|
||||
|
||||
+20
-11
@@ -20,7 +20,7 @@ Your journey begins with a generous free tier, perfect for experimentation and l
|
||||
|
||||
Your free usage limits depend on your authorization type.
|
||||
|
||||
### Log in with Google (Gemini Code Assist for Individuals)
|
||||
### Log in with Google (Gemini Code Assist for individuals)
|
||||
|
||||
For users who authenticate by using their Google account to access Gemini Code Assist for individuals. This includes:
|
||||
|
||||
@@ -51,17 +51,26 @@ Learn more at [Vertex AI Express Mode Limits](https://cloud.google.com/vertex-ai
|
||||
|
||||
## Paid tier: Higher limits for a fixed cost
|
||||
|
||||
If you use up your initial number of requests, you can upgrade your plan to continue to benefit from Gemini CLI by using the [Standard or Enterprise editions of Gemini Code Assist](https://cloud.google.com/products/gemini/pricing) by signing up [here](https://goo.gle/set-up-gemini-code-assist). Quotas and pricing are based on a fixed price subscription with assigned license seats. For predictable costs, you can log in with Google. This includes:
|
||||
If you use up your initial number of requests, you can continue to benefit from Gemini CLI by upgrading to one of the following subscriptions:
|
||||
|
||||
- Standard:
|
||||
- 1500 model requests / user / day
|
||||
- 120 model requests / user / minute
|
||||
- Enterprise:
|
||||
- 2000 model requests / user / day
|
||||
- 120 model requests / user / minute
|
||||
- Model requests will be made across the Gemini model family as determined by Gemini CLI.
|
||||
- [Google AI Pro and AI Ultra](https://cloud.google.com/products/gemini/pricing) by signing up at [Set up Gemini Code Assist](https://goo.gle/set-up-gemini-code-assist). This is recommended for individual developers. Quotas and pricing are based on a fixed price subscription.
|
||||
|
||||
Learn more at [Gemini Code Assist Standard and Enterprise license Limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli).
|
||||
For predictable costs, you can log in with Google.
|
||||
|
||||
Learn more at [Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas)
|
||||
|
||||
- [Purchase a Gemini Code Assist Subscription through Google Cloud ](https://cloud.google.com/gemini/docs/codeassist/overview) by signing up in the Google Cloud console. Learn more at [Set up Gemini Code Assist] (https://cloud.google.com/gemini/docs/discover/set-up-gemini) Quotas and pricing are based on a fixed price subscription with assigned license seats. For predictable costs, you can sign in with Google.
|
||||
|
||||
This includes:
|
||||
- Gemini Code Assist Standard edition:
|
||||
- 1500 model requests / user / day
|
||||
- 120 model requests / user / minute
|
||||
- Gemini Code Assist Enterprise edition:
|
||||
- 2000 model requests / user / day
|
||||
- 120 model requests / user / minute
|
||||
- Model requests will be made across the Gemini model family as determined by Gemini CLI.
|
||||
|
||||
[Learn more about Gemini Code Assist Standard and Enterprise license limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli).
|
||||
|
||||
## Pay As You Go
|
||||
|
||||
@@ -83,7 +92,7 @@ Learn more at [Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rat
|
||||
|
||||
It’s important to highlight that when using an API key, you pay per token/call. This can be more expensive for many small calls with few tokens, but it's the only way to ensure your workflow isn't interrupted by quota limits.
|
||||
|
||||
## Google One and Ultra plans, Gemini for Workspace plans
|
||||
## Gemini for Workspace plans
|
||||
|
||||
These plans currently apply only to the use of Gemini web-based products provided by Google-based experiences (for example, the Gemini web app or the Flow video editor). These plans do not apply to the API usage which powers the Gemini CLI. Supporting these plans is under active consideration for future support.
|
||||
|
||||
|
||||
+209
-212
@@ -4,9 +4,16 @@
|
||||
|
||||
We will follow https://semver.org/ as closely as possible but will call out when or if we have to deviate from it. Our weekly releases will be minor version increments and any bug or hotfixes between releases will go out as patch versions on the most recent release.
|
||||
|
||||
Each Tuesday ~2000 UTC new Stable and Preview releases will be cut. The promotion flow is:
|
||||
|
||||
- Code is commited to main and pushed each night to nightly
|
||||
- After no more than 1 week on main, code is promoted to the `preview` channel
|
||||
- After 1 week the most recent `preview` channel is promoted to `stable` cannel
|
||||
- Patch fixes will be produced against both `preview` and `stable` as needed, with the final 'patch' version number incrementing each time.
|
||||
|
||||
### Preview
|
||||
|
||||
New preview releases will be published each week at UTC 2359 on Tuesdays. These releases will not have been fully vetted and may contain regressions or other outstanding issues. Please help us test and install with `preview` tag.
|
||||
These releases will not have been fully vetted and may contain regressions or other outstanding issues. Please help us test and install with `preview` tag.
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@preview
|
||||
@@ -14,7 +21,7 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
### Stable
|
||||
|
||||
- New stable releases will be published each week at UTC 2000 on Tuesdays, this will be the full promotion of last week's release + any bug fixes and validations. Use `latest` tag.
|
||||
This will be the full promotion of last week's release + any bug fixes and validations. Use `latest` tag.
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@latest
|
||||
@@ -22,20 +29,12 @@ npm install -g @google/gemini-cli@latest
|
||||
|
||||
### Nightly
|
||||
|
||||
- New releases will be published each week at UTC 0000 each day, This will be all changes from the main branch as represted at time of release. It should be assumed there are pending validations and issues. Use `nightly` tag.
|
||||
- New releases will be published each day at UTC 0000. This will be all changes from the main branch as represented at time of release. It should be assumed there are pending validations and issues. Use `nightly` tag.
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@nightly
|
||||
```
|
||||
|
||||
# Release Process
|
||||
|
||||
Our release cadence is new releases are sent to a preview channel for a week and then promoted to stable after a week. Version numbers will follow SemVer with weekly releases incrementing the minor version. Patches and bug fixes to both preview and stable releases will increment the patch version.
|
||||
|
||||
## Nightly Release
|
||||
|
||||
Each night at UTC 0000 we will auto deploy a nightly release from `main`. This will be a version of the next production release, x.y.z, with the nightly tag.
|
||||
|
||||
## Weekly Release Promotion
|
||||
|
||||
Each Tuesday, the on-call engineer will trigger the "Promote Release" workflow. This single action automates the entire weekly release process:
|
||||
@@ -59,169 +58,198 @@ To ensure the highest reliability, the release promotion process uses the **NPM
|
||||
|
||||
This NPM-first approach, backed by integrity checks, makes the release process highly robust and prevents the kinds of versioning discrepancies that can arise from relying solely on git history or API outputs.
|
||||
|
||||
## Patching Releases
|
||||
## Manual Releases
|
||||
|
||||
For situations requiring a release outside of the regular nightly and weekly promotion schedule, and NOT already covered by patching process, you can use the `Release: Manual` workflow. This workflow provides a direct way to publish a specific version from any branch, tag, or commit SHA.
|
||||
|
||||
### How to Create a Manual Release
|
||||
|
||||
1. Navigate to the **Actions** tab of the repository.
|
||||
2. Select the **Release: Manual** workflow from the list.
|
||||
3. Click the **Run workflow** dropdown button.
|
||||
4. Fill in the required inputs:
|
||||
- **Version**: The exact version to release (e.g., `v0.6.1`). This must be a valid semantic version with a `v` prefix.
|
||||
- **Ref**: The branch, tag, or full commit SHA to release from.
|
||||
- **NPM Channel**: The npm channel to publish to. The options are `preview`, `nightly`, `latest` (for stable releases), and `dev`. The default is `dev`.
|
||||
- **Dry Run**: Leave as `true` to run all steps without publishing, or set to `false` to perform a live release.
|
||||
- **Force Skip Tests**: Set to `true` to skip the test suite. This is not recommended for production releases.
|
||||
- **Skip GitHub Release**: Set to `true` to skip creating a GitHub release and create an npm release only.
|
||||
5. Click **Run workflow**.
|
||||
|
||||
The workflow will then proceed to test (if not skipped), build, and publish the release. If the workflow fails during a non-dry run, it will automatically create a GitHub issue with the failure details.
|
||||
|
||||
## Rollback/Rollforward
|
||||
|
||||
In the event that a release has a critical regression, you can quickly roll back to a previous stable version or roll forward to a new patch by changing the npm `dist-tag`. The `Release: Change Tags` workflow provides a safe and controlled way to do this.
|
||||
|
||||
This is the preferred method for both rollbacks and rollforwards, as it does not require a full release cycle.
|
||||
|
||||
### How to Change a Release Tag
|
||||
|
||||
1. Navigate to the **Actions** tab of the repository.
|
||||
2. Select the **Release: Change Tags** workflow from the list.
|
||||
3. Click the **Run workflow** dropdown button.
|
||||
4. Fill in the required inputs:
|
||||
- **Version**: The existing package version that you want to point the tag to (e.g., `0.5.0-preview-2`). This version **must** already be published to the npm registry.
|
||||
- **Channel**: The npm `dist-tag` to apply (e.g., `preview`, `stable`).
|
||||
- **Dry Run**: Leave as `true` to log the action without making changes, or set to `false` to perform the live tag change.
|
||||
5. Click **Run workflow**.
|
||||
|
||||
The workflow will then run `npm dist-tag add` for both the `@google/gemini-cli` and `@google/gemini-cli-core` packages, pointing the specified channel to the specified version.
|
||||
|
||||
## Patching
|
||||
|
||||
If a critical bug that is already fixed on `main` needs to be patched on a `stable` or `preview` release, the process is now highly automated.
|
||||
|
||||
### 1. Create the Patch Pull Request
|
||||
### How to Patch
|
||||
|
||||
#### 1. Create the Patch Pull Request
|
||||
|
||||
There are two ways to create a patch pull request:
|
||||
|
||||
**Option A: From a GitHub Comment (Recommended)**
|
||||
|
||||
After a pull request has been merged, a maintainer can add a comment on that same PR with the following format:
|
||||
After a pull request containing the fix has been merged, a maintainer can add a comment on that same PR with the following format:
|
||||
|
||||
`/patch <channel> [--dry-run]`
|
||||
`/patch [channel]`
|
||||
|
||||
- **channel**: `stable` or `preview`
|
||||
- **--dry-run** (optional): If included, the workflow will run in dry-run mode. This will create the PR with "[DRY RUN]" in the title, and merging it will trigger a dry run of the final release, so nothing is actually published.
|
||||
- **channel** (optional):
|
||||
- _no channel_ - patches both stable and preview channels (default, recommended for most fixes)
|
||||
- `both` - patches both stable and preview channels (same as default)
|
||||
- `stable` - patches only the stable channel
|
||||
- `preview` - patches only the preview channel
|
||||
|
||||
Example: `/patch stable --dry-run`
|
||||
Examples:
|
||||
|
||||
The workflow will automatically find the merge commit SHA and begin the patch process. If the PR is not yet merged, it will post a comment indicating the failure.
|
||||
- `/patch` (patches both stable and preview - default)
|
||||
- `/patch both` (patches both stable and preview - explicit)
|
||||
- `/patch stable` (patches only stable)
|
||||
- `/patch preview` (patches only preview)
|
||||
|
||||
The `Release: Patch from Comment` workflow will automatically find the merge commit SHA and trigger the `Release: Patch (1) Create PR` workflow. If the PR is not yet merged, it will post a comment indicating the failure.
|
||||
|
||||
**Option B: Manually Triggering the Workflow**
|
||||
|
||||
Follow the manual release process using the "Patch Release" GitHub Actions workflow.
|
||||
|
||||
- **Type**: Select whether you are patching a `stable` or `preview` release. The workflow will automatically calculate the next patch version.
|
||||
- **Ref**: Use your source branch as the reference (ex. `release/v0.2.0-preview.0`)
|
||||
Navigate to the **Actions** tab and run the **Create Patch PR** workflow.
|
||||
Navigate to the **Actions** tab and run the **Release: Patch (1) Create PR** workflow.
|
||||
|
||||
- **Commit**: The full SHA of the commit on `main` that you want to cherry-pick.
|
||||
- **Channel**: The channel you want to patch (`stable` or `preview`).
|
||||
|
||||
This workflow will automatically:
|
||||
|
||||
1. Find the latest release tag for the channel.
|
||||
2. Create a release branch from that tag if one doesn't exist (e.g., `release/v0.5.1`).
|
||||
3. Create a new hotfix branch from the release branch.
|
||||
4. Cherry-pick your specified commit into the hotfix branch.
|
||||
5. Create a pull request from the hotfix branch back to the release branch.
|
||||
1. Find the latest release tag for the channel.
|
||||
2. Create a release branch from that tag if one doesn't exist (e.g., `release/v0.5.1`).
|
||||
3. Create a new hotfix branch from the release branch.
|
||||
4. Cherry-pick your specified commit into the hotfix branch.
|
||||
5. Create a pull request from the hotfix branch back to the release branch.
|
||||
|
||||
**Important:** If you select `stable`, the workflow will run twice, creating one PR for the `stable` channel and a second PR for the `preview` channel.
|
||||
|
||||
### 2. Review and Merge
|
||||
#### 2. Review and Merge
|
||||
|
||||
Review the automatically created pull request(s) to ensure the cherry-pick was successful and the changes are correct. Once approved, merge the pull request.
|
||||
|
||||
**Security Note:** The `release/*` branches are protected by branch protection rules. A pull request to one of these branches requires at least one review from a code owner before it can be merged. This ensures that no unauthorized code is released.
|
||||
|
||||
### 3. Automatic Release
|
||||
#### 2.5. Adding Multiple Commits to a Hotfix (Advanced)
|
||||
|
||||
Upon merging the pull request, a final workflow is automatically triggered. It will:
|
||||
If you need to include multiple fixes in a single patch release, you can add additional commits to the hotfix branch after the initial patch PR has been created:
|
||||
|
||||
1. Run the `patch-release` workflow.
|
||||
2. Build and test the patched code.
|
||||
3. Publish the new patch version to npm.
|
||||
4. Create a new GitHub release with the patch notes.
|
||||
1. **Start with the primary fix**: Use `/patch` (or `/patch both`) on the most important PR to create the initial hotfix branch and PR.
|
||||
|
||||
2. **Checkout the hotfix branch locally**:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout hotfix/v0.5.1/stable/cherry-pick-abc1234 # Use the actual branch name from the PR
|
||||
```
|
||||
|
||||
3. **Cherry-pick additional commits**:
|
||||
|
||||
```bash
|
||||
git cherry-pick <commit-sha-1>
|
||||
git cherry-pick <commit-sha-2>
|
||||
# Add as many commits as needed
|
||||
```
|
||||
|
||||
4. **Push the updated branch**:
|
||||
|
||||
```bash
|
||||
git push origin hotfix/v0.5.1/stable/cherry-pick-abc1234
|
||||
```
|
||||
|
||||
5. **Test and review**: The existing patch PR will automatically update with your additional commits. Test thoroughly since you're now releasing multiple changes together.
|
||||
|
||||
6. **Update the PR description**: Consider updating the PR title and description to reflect that it includes multiple fixes.
|
||||
|
||||
This approach allows you to group related fixes into a single patch release while maintaining full control over what gets included and how conflicts are resolved.
|
||||
|
||||
#### 3. Automatic Release
|
||||
|
||||
Upon merging the pull request, the `Release: Patch (2) Trigger` workflow is automatically triggered. It will then start the `Release: Patch (3) Release` workflow, which will:
|
||||
|
||||
1. Build and test the patched code.
|
||||
2. Publish the new patch version to npm.
|
||||
3. Create a new GitHub release with the patch notes.
|
||||
|
||||
This fully automated process ensures that patches are created and released consistently and reliably.
|
||||
|
||||
## Release Schedule
|
||||
#### Troubleshooting: Older Branch Workflows
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td>Date
|
||||
</td>
|
||||
<td>Stable UTC 2000
|
||||
</td>
|
||||
<td>Preview UTC 2359
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aug 19th, 2025
|
||||
</td>
|
||||
<td>N/A
|
||||
</td>
|
||||
<td>0.2.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aug 26th, 2025
|
||||
</td>
|
||||
<td>0.2.0
|
||||
</td>
|
||||
<td>0.3.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sep 2nd, 2025
|
||||
</td>
|
||||
<td>0.3.0
|
||||
</td>
|
||||
<td>0.4.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sep 9th, 2025
|
||||
</td>
|
||||
<td>0.4.0
|
||||
</td>
|
||||
<td>0.5.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sep 16th, 2025
|
||||
</td>
|
||||
<td>0.5.0
|
||||
</td>
|
||||
<td>0.6.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sep 23rd, 2025
|
||||
</td>
|
||||
<td>0.6.0
|
||||
</td>
|
||||
<td>0.7.0-preview.0
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
**Issue**: If the patch trigger workflow fails with errors like "Resource not accessible by integration" or references to non-existent workflow files (e.g., `patch-release.yml`), this indicates the hotfix branch contains an outdated version of the workflow files.
|
||||
|
||||
## How To Release
|
||||
**Root Cause**: When a PR is merged, GitHub Actions runs the workflow definition from the **source branch** (the hotfix branch), not from the target branch (the release branch). If the hotfix branch was created from an older release branch that predates workflow improvements, it will use the old workflow logic.
|
||||
|
||||
Releases are managed through GitHub Actions workflows.
|
||||
**Solutions**:
|
||||
|
||||
### Weekly Promotions
|
||||
**Option 1: Manual Trigger (Quick Fix)**
|
||||
Manually trigger the updated workflow from the branch with the latest workflow code:
|
||||
|
||||
To perform the weekly promotion of `preview` to `stable` and `nightly` to `preview`:
|
||||
```bash
|
||||
# For a preview channel patch with tests skipped
|
||||
gh workflow run release-patch-2-trigger.yml --ref <branch-with-updated-workflow> \
|
||||
--field ref="hotfix/v0.6.0-preview.2/preview/cherry-pick-abc1234" \
|
||||
--field workflow_ref=<branch-with-updated-workflow> \
|
||||
--field dry_run=false \
|
||||
--field force_skip_tests=true
|
||||
|
||||
1. Navigate to the **Actions** tab of the repository.
|
||||
2. Select the **Promote Release** workflow from the list.
|
||||
3. Click the **Run workflow** dropdown button.
|
||||
4. Leave **Dry Run** as `true` to test the workflow without publishing, or set to `false` to perform a live release.
|
||||
5. Click **Run workflow**.
|
||||
# For a stable channel patch
|
||||
gh workflow run release-patch-2-trigger.yml --ref <branch-with-updated-workflow> \
|
||||
--field ref="hotfix/v0.5.1/stable/cherry-pick-abc1234" \
|
||||
--field workflow_ref=<branch-with-updated-workflow> \
|
||||
--field dry_run=false \
|
||||
--field force_skip_tests=false
|
||||
|
||||
### Patching a Release
|
||||
# Example using main branch (most common case)
|
||||
gh workflow run release-patch-2-trigger.yml --ref main \
|
||||
--field ref="hotfix/v0.6.0-preview.2/preview/cherry-pick-abc1234" \
|
||||
--field workflow_ref=main \
|
||||
--field dry_run=false \
|
||||
--field force_skip_tests=true
|
||||
```
|
||||
|
||||
To perform a manual release for a patch or hotfix:
|
||||
**Note**: Replace `<branch-with-updated-workflow>` with the branch containing the latest workflow improvements (usually `main`, but could be a feature branch if testing updates).
|
||||
|
||||
1. Navigate to the **Actions** tab of the repository.
|
||||
2. Select the **Patch Release** workflow from the list.
|
||||
3. Click the **Run workflow** dropdown button.
|
||||
4. Fill in the required inputs:
|
||||
- **Type**: Select whether you are patching a `stable` or `preview` release.
|
||||
- **Ref**: The branch or commit SHA to release from.
|
||||
- **Dry Run**: Leave as `true` to test the workflow without publishing, or set to `false` to perform a live release.
|
||||
5. Click **Run workflow**.
|
||||
**Option 2: Update the Hotfix Branch**
|
||||
Merge the latest main branch into your hotfix branch to get the updated workflows:
|
||||
|
||||
### TLDR
|
||||
```bash
|
||||
git checkout hotfix/v0.6.0-preview.2/preview/cherry-pick-abc1234
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
Each release, wether automated or manual performs the following steps:
|
||||
Then close and reopen the PR to retrigger the workflow with the updated version.
|
||||
|
||||
1. Checks out the latest code from the `main` branch.
|
||||
1. Installs all dependencies.
|
||||
1. Runs the full suite of `preflight` checks and integration tests.
|
||||
1. If all tests succeed, it calculates the next version number based on the inputs.
|
||||
1. It creates a branch name `release/${VERSION}`.
|
||||
1. It creates a tag name `v${VERSION}`.
|
||||
1. It then builds and publishes the packages to npm with the provided version number.
|
||||
1. Finally, it creates a GitHub Release for the version.
|
||||
**Option 3: Direct Release Trigger**
|
||||
Skip the trigger workflow entirely and directly run the release workflow:
|
||||
|
||||
### Failure Handling
|
||||
|
||||
If any step in the workflow fails, it will automatically create a new issue in the repository with the labels `bug` and `release-failure`. The issue will contain a link to the failed workflow run for easy debugging.
|
||||
```bash
|
||||
# Replace channel and release_ref with appropriate values
|
||||
gh workflow run release-patch-3-release.yml --ref main \
|
||||
--field type="preview" \
|
||||
--field dry_run=false \
|
||||
--field force_skip_tests=true \
|
||||
--field release_ref="release/v0.6.0-preview.2"
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -240,7 +268,7 @@ After pushing a new release smoke testing should be performed to ensure that the
|
||||
|
||||
If you need to test the release process without actually publishing to NPM or creating a public GitHub release, you can trigger the workflow manually from the GitHub UI.
|
||||
|
||||
1. Go to the [Actions tab](https://github.com/google-gemini/gemini-cli/actions/workflows/release.yml) of the repository.
|
||||
1. Go to the [Actions tab](https://github.com/google-gemini/gemini-cli/actions/workflows/release-manual.yml) of the repository.
|
||||
2. Click on the "Run workflow" dropdown.
|
||||
3. Leave the `dry_run` option checked (`true`).
|
||||
4. Click the "Run workflow" button.
|
||||
@@ -268,105 +296,74 @@ By performing a dry run, you can be confident that your changes to the packaging
|
||||
|
||||
## Release Deep Dive
|
||||
|
||||
The main goal of the release process is to take the source code from the packages/ directory, build it, and assemble a
|
||||
clean, self-contained package in a temporary `bundle` directory at the root of the project. This `bundle` directory is what
|
||||
actually gets published to NPM.
|
||||
The release process creates two distinct types of artifacts for different distribution channels: standard packages for the NPM registry and a single, self-contained executable for GitHub Releases.
|
||||
|
||||
Here are the key stages:
|
||||
|
||||
Stage 1: Pre-Release Sanity Checks and Versioning
|
||||
**Stage 1: Pre-Release Sanity Checks and Versioning**
|
||||
|
||||
- What happens: Before any files are moved, the process ensures the project is in a good state. This involves running tests,
|
||||
linting, and type-checking (npm run preflight). The version number in the root package.json and packages/cli/package.json
|
||||
is updated to the new release version.
|
||||
- Why: This guarantees that only high-quality, working code is released. Versioning is the first step to signify a new
|
||||
release.
|
||||
- **What happens:** Before any files are moved, the process ensures the project is in a good state. This involves running tests, linting, and type-checking (`npm run preflight`). The version number in the root `package.json` and `packages/cli/package.json` is updated to the new release version.
|
||||
|
||||
Stage 2: Building the Source Code
|
||||
**Stage 2: Building the Source Code for NPM**
|
||||
|
||||
- What happens: The TypeScript source code in packages/core/src and packages/cli/src is compiled into JavaScript.
|
||||
- File movement:
|
||||
- packages/core/src/\*_/_.ts -> compiled to -> packages/core/dist/
|
||||
- packages/cli/src/\*_/_.ts -> compiled to -> packages/cli/dist/
|
||||
- Why: The TypeScript code written during development needs to be converted into plain JavaScript that can be run by
|
||||
Node.js. The core package is built first as the cli package depends on it.
|
||||
- **What happens:** The TypeScript source code in `packages/core/src` and `packages/cli/src` is compiled into standard JavaScript.
|
||||
- **File movement:**
|
||||
- `packages/core/src/**/*.ts` -> compiled to -> `packages/core/dist/`
|
||||
- `packages/cli/src/**/*.ts` -> compiled to -> `packages/cli/dist/`
|
||||
- **Why:** The TypeScript code written during development needs to be converted into plain JavaScript that can be run by Node.js. The `core` package is built first as the `cli` package depends on it.
|
||||
|
||||
Stage 3: Assembling the Final Publishable Package
|
||||
**Stage 3: Publishing Standard Packages to NPM**
|
||||
|
||||
This is the most critical stage where files are moved and transformed into their final state for publishing. A temporary
|
||||
`bundle` folder is created at the project root to house the final package contents.
|
||||
- **What happens:** The `npm publish` command is run for the `@google/gemini-cli-core` and `@google/gemini-cli` packages.
|
||||
- **Why:** This publishes them as standard Node.js packages. Users installing via `npm install -g @google/gemini-cli` will download these packages, and `npm` will handle installing the `@google/gemini-cli-core` dependency automatically. The code in these packages is not bundled into a single file.
|
||||
|
||||
1. The `package.json` is Transformed:
|
||||
- What happens: The package.json from packages/cli/ is read, modified, and written into the root `bundle`/ directory.
|
||||
- File movement: packages/cli/package.json -> (in-memory transformation) -> `bundle`/package.json
|
||||
- Why: The final package.json must be different from the one used in development. Key changes include:
|
||||
- Removing devDependencies.
|
||||
- Removing workspace-specific "dependencies": { "@gemini-cli/core": "workspace:\*" } and ensuring the core code is
|
||||
bundled directly into the final JavaScript file.
|
||||
- Ensuring the bin, main, and files fields point to the correct locations within the final package structure.
|
||||
**Stage 4: Assembling and Creating the GitHub Release Asset**
|
||||
|
||||
2. The JavaScript Bundle is Created:
|
||||
- What happens: The built JavaScript from both packages/core/dist and packages/cli/dist are bundled into a single,
|
||||
executable JavaScript file.
|
||||
- File movement: packages/cli/dist/index.js + packages/core/dist/index.js -> (bundled by esbuild) -> `bundle`/gemini.js (or a
|
||||
similar name).
|
||||
- Why: This creates a single, optimized file that contains all the necessary application code. It simplifies the package
|
||||
by removing the need for the core package to be a separate dependency on NPM, as its code is now included directly.
|
||||
This stage happens _after_ the NPM publish and creates the single-file executable that enables `npx` usage directly from the GitHub repository.
|
||||
|
||||
3. Static and Supporting Files are Copied:
|
||||
- What happens: Essential files that are not part of the source code but are required for the package to work correctly
|
||||
or be well-described are copied into the `bundle` directory.
|
||||
- File movement:
|
||||
- README.md -> `bundle`/README.md
|
||||
- LICENSE -> `bundle`/LICENSE
|
||||
- packages/cli/src/utils/\*.sb (sandbox profiles) -> `bundle`/
|
||||
- Why:
|
||||
- The README.md and LICENSE are standard files that should be included in any NPM package.
|
||||
- The sandbox profiles (.sb files) are critical runtime assets required for the CLI's sandboxing feature to
|
||||
function. They must be located next to the final executable.
|
||||
1. **The JavaScript Bundle is Created:**
|
||||
- **What happens:** The built JavaScript from both `packages/core/dist` and `packages/cli/dist`, along with all third-party JavaScript dependencies, are bundled by `esbuild` into a single, executable JavaScript file (e.g., `gemini.js`). The `node-pty` library is excluded from this bundle as it contains native binaries.
|
||||
- **Why:** This creates a single, optimized file that contains all the necessary application code. It simplifies execution for users who want to run the CLI without a full `npm install`, as all dependencies (including the `core` package) are included directly.
|
||||
|
||||
Stage 4: Publishing to NPM
|
||||
2. **The `bundle` Directory is Assembled:**
|
||||
- **What happens:** A temporary `bundle` folder is created at the project root. The single `gemini.js` executable is placed inside it, along with other essential files.
|
||||
- **File movement:**
|
||||
- `gemini.js` (from esbuild) -> `bundle/gemini.js`
|
||||
- `README.md` -> `bundle/README.md`
|
||||
- `LICENSE` -> `bundle/LICENSE`
|
||||
- `packages/cli/src/utils/*.sb` (sandbox profiles) -> `bundle/`
|
||||
- **Why:** This creates a clean, self-contained directory with everything needed to run the CLI and understand its license and usage.
|
||||
|
||||
- What happens: The npm publish command is run from inside the root `bundle` directory.
|
||||
- Why: By running npm publish from within the `bundle` directory, only the files we carefully assembled in Stage 3 are uploaded
|
||||
to the NPM registry. This prevents any source code, test files, or development configurations from being accidentally
|
||||
published, resulting in a clean and minimal package for users.
|
||||
3. **The GitHub Release is Created:**
|
||||
- **What happens:** The contents of the `bundle` directory, including the `gemini.js` executable, are attached as assets to a new GitHub Release.
|
||||
- **Why:** This makes the single-file version of the CLI available for direct download and enables the `npx https://github.com/google-gemini/gemini-cli` command, which downloads and runs this specific bundled asset.
|
||||
|
||||
Summary of File Flow
|
||||
**Summary of Artifacts**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Source Files"
|
||||
A["packages/core/src/*.ts<br/>packages/cli/src/*.ts"]
|
||||
B["packages/cli/package.json"]
|
||||
C["README.md<br/>LICENSE<br/>packages/cli/src/utils/*.sb"]
|
||||
end
|
||||
- **NPM:** Publishes standard, un-bundled Node.js packages. The primary artifact is the code in `packages/cli/dist`, which depends on `@google/gemini-cli-core`.
|
||||
- **GitHub Release:** Publishes a single, bundled `gemini.js` file that contains all dependencies, for easy execution via `npx`.
|
||||
|
||||
subgraph "Process"
|
||||
D(Build)
|
||||
E(Transform)
|
||||
F(Assemble)
|
||||
G(Publish)
|
||||
end
|
||||
This dual-artifact process ensures that both traditional `npm` users and those who prefer the convenience of `npx` have an optimized experience.
|
||||
|
||||
subgraph "Artifacts"
|
||||
H["Bundled JS"]
|
||||
I["Final package.json"]
|
||||
J["bundle/"]
|
||||
end
|
||||
## Notifications
|
||||
|
||||
subgraph "Destination"
|
||||
K["NPM Registry"]
|
||||
end
|
||||
Failing release workflows will automatically create an issue with the label
|
||||
`release-failure`.
|
||||
|
||||
A --> D --> H
|
||||
B --> E --> I
|
||||
C --> F
|
||||
H --> F
|
||||
I --> F
|
||||
F --> J
|
||||
J --> G --> K
|
||||
```
|
||||
A notification will be posted to the maintainer's chat channel when issues with
|
||||
this type are created.
|
||||
|
||||
This process ensures that the final published artifact is a purpose-built, clean, and efficient representation of the
|
||||
project, rather than a direct copy of the development workspace.
|
||||
### Modifying chat notifications
|
||||
|
||||
Notifications use [GitHub for Google Chat](https://workspace.google.com/marketplace/app/github_for_google_chat/536184076190). To modify the notifications, use `/github-settings` within the chat space.
|
||||
|
||||
> [!WARNING]
|
||||
> The following instructions describe a fragile workaround that depends on the internal structure of the chat application's UI. It is likely to break with future updates.
|
||||
|
||||
The list of available labels is not currently populated correctly. If you want to add a label that does not appear alphabetically in the first 30 labels in the repo, you must use your browser's developer tools to manually modify the UI:
|
||||
|
||||
1. Open your browser's developer tools (e.g., Chrome DevTools).
|
||||
2. In the `/github-settings` dialog, inspect the list of labels.
|
||||
3. Locate one of the `<li>` elements representing a label.
|
||||
4. In the HTML, modify the `data-option-value` attribute of that `<li>` element to the desired label name (e.g., `release-failure`).
|
||||
5. Click on your modified label in the UI to select it, then save your settings.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"items": [
|
||||
{ "label": "Welcome", "slug": "docs" },
|
||||
{ "label": "Execution and Deployment", "slug": "docs/deployment" },
|
||||
{ "label": "Architecture Overview", "slug": "docs/architecture" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "CLI",
|
||||
"items": [
|
||||
{ "label": "Introduction", "slug": "docs/cli" },
|
||||
{ "label": "Authentication", "slug": "docs/cli/authentication" },
|
||||
{ "label": "Commands", "slug": "docs/cli/commands" },
|
||||
{ "label": "Configuration", "slug": "docs/cli/configuration" },
|
||||
{ "label": "Checkpointing", "slug": "docs/checkpointing" },
|
||||
{ "label": "Enterprise", "slug": "docs/cli/enterprise" },
|
||||
{ "label": "Extensions", "slug": "docs/extension" },
|
||||
{ "label": "Headless Mode", "slug": "docs/headless" },
|
||||
{ "label": "IDE Integration", "slug": "docs/ide-integration" },
|
||||
{
|
||||
"label": "IDE Companion Spec",
|
||||
"slug": "docs/ide-companion-spec"
|
||||
},
|
||||
{ "label": "Telemetry", "slug": "docs/telemetry" },
|
||||
{ "label": "Themes", "slug": "docs/cli/themes" },
|
||||
{ "label": "Token Caching", "slug": "docs/cli/token-caching" },
|
||||
{ "label": "Trusted Folders", "slug": "docs/trusted-folders" },
|
||||
{ "label": "Tutorials", "slug": "docs/cli/tutorials" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Core",
|
||||
"items": [
|
||||
{ "label": "Introduction", "slug": "docs/core" },
|
||||
{ "label": "Tools API", "slug": "docs/core/tools-api" },
|
||||
{ "label": "Memory Import Processor", "slug": "docs/core/memport" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Tools",
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/tools" },
|
||||
{ "label": "File System", "slug": "docs/tools/file-system" },
|
||||
{ "label": "Multi-File Read", "slug": "docs/tools/multi-file" },
|
||||
{ "label": "Shell", "slug": "docs/tools/shell" },
|
||||
{ "label": "Web Fetch", "slug": "docs/tools/web-fetch" },
|
||||
{ "label": "Web Search", "slug": "docs/tools/web-search" },
|
||||
{ "label": "Memory", "slug": "docs/tools/memory" },
|
||||
{ "label": "MCP Servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Sandboxing", "slug": "docs/sandbox" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Development",
|
||||
"items": [
|
||||
{ "label": "NPM", "slug": "docs/npm" },
|
||||
{ "label": "Releases", "slug": "docs/releases" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Support",
|
||||
"items": [
|
||||
{ "label": "Troubleshooting", "slug": "docs/troubleshooting" },
|
||||
{ "label": "Terms of Service", "slug": "docs/tos-privacy" }
|
||||
]
|
||||
}
|
||||
]
|
||||
+15
-11
@@ -48,18 +48,22 @@ observability framework — Gemini CLI's observability system provides:
|
||||
|
||||
## Configuration
|
||||
|
||||
All telemetry behavior is controlled through your `.gemini/settings.json` file
|
||||
and can be overridden with CLI flags:
|
||||
All telemetry behavior is controlled through your `.gemini/settings.json` file.
|
||||
These settings can be overridden by environment variables or CLI flags.
|
||||
|
||||
| Setting | Values | Default | CLI Override | Description |
|
||||
| -------------- | ----------------- | ----------------------- | -------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `enabled` | `true`/`false` | `false` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry |
|
||||
| `target` | `"gcp"`/`"local"` | `"local"` | `--telemetry-target <local\|gcp>` | Where to send telemetry data |
|
||||
| `otlpEndpoint` | URL string | `http://localhost:4317` | `--telemetry-otlp-endpoint <URL>` | OTLP collector endpoint |
|
||||
| `otlpProtocol` | `"grpc"`/`"http"` | `"grpc"` | `--telemetry-otlp-protocol <grpc\|http>` | OTLP transport protocol |
|
||||
| `outfile` | file path | - | `--telemetry-outfile <path>` | Save telemetry to file (requires `otlpEndpoint: ""`) |
|
||||
| `logPrompts` | `true`/`false` | `true` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs |
|
||||
| `useCollector` | `true`/`false` | `false` | - | Use external OTLP collector (advanced) |
|
||||
| Setting | Environment Variable | CLI Flag | Description | Values | Default |
|
||||
| -------------- | -------------------------------- | -------------------------------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- |
|
||||
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` |
|
||||
| `target` | `GEMINI_TELEMETRY_TARGET` | `--telemetry-target <local\|gcp>` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
|
||||
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint <URL>` | OTLP collector endpoint | URL string | `http://localhost:4317` |
|
||||
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol <grpc\|http>` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
|
||||
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | `--telemetry-outfile <path>` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
|
||||
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` |
|
||||
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` |
|
||||
|
||||
**Note on boolean environment variables:** For the boolean settings (`enabled`,
|
||||
`logPrompts`, `useCollector`), setting the corresponding environment variable to
|
||||
`true` or `1` will enable the feature. Any other value will disable it.
|
||||
|
||||
For detailed information about all configuration options, see the
|
||||
[Configuration Guide](./cli/configuration.md).
|
||||
|
||||
@@ -117,6 +117,8 @@ Each server configuration supports the following properties:
|
||||
- **`trust`** (boolean): When `true`, bypasses all tool call confirmations for this server (default: `false`)
|
||||
- **`includeTools`** (string[]): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (allowlist behavior). If not specified, all tools from the server are enabled by default.
|
||||
- **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded.
|
||||
- **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`.
|
||||
- **`targetServiceAccount`** (string): The email address of the Google Cloud Service Account to impersonate. Used with `authProviderType: 'service_account_impersonation'`.
|
||||
|
||||
### OAuth Support for Remote MCP Servers
|
||||
|
||||
@@ -210,6 +212,9 @@ You can specify the authentication provider type using the `authProviderType` pr
|
||||
- **`authProviderType`** (string): Specifies the authentication provider. Can be one of the following:
|
||||
- **`dynamic_discovery`** (default): The CLI will automatically discover the OAuth configuration from the server.
|
||||
- **`google_credentials`**: The CLI will use the Google Application Default Credentials (ADC) to authenticate with the server. When using this provider, you must specify the required scopes.
|
||||
- **`service_account_impersonation`**: The CLI will impersonate a Google Cloud Service Account to authenticate with the server. This is useful for accessing IAP-protected services (this was specifically designed for Cloud Run services).
|
||||
|
||||
#### Google Credentials
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -225,6 +230,24 @@ You can specify the authentication provider type using the `authProviderType` pr
|
||||
}
|
||||
```
|
||||
|
||||
#### Service Account Impersonation
|
||||
|
||||
To authenticate with a server using Service Account Impersonation, you must set the `authProviderType` to `service_account_impersonation` and provide the following properties:
|
||||
|
||||
- **`targetAudience`** (string): The OAuth Client ID allowslisted on the IAP-protected application you are trying to access.
|
||||
- **`targetServiceAccount`** (string): The email address of the Google Cloud Service Account to impersonate.
|
||||
|
||||
The CLI will use your local Application Default Credentials (ADC) to generate an OIDC ID token for the specified service account and audience. This token will then be used to authenticate with the MCP server.
|
||||
|
||||
#### Setup Instructions
|
||||
|
||||
1. **[Create](https://cloud.google.com/iap/docs/oauth-client-creation) or use an existing OAuth 2.0 client ID.** To use an existing OAuth 2.0 client ID, follow the steps in [How to share OAuth Clients](https://cloud.google.com/iap/docs/sharing-oauth-clients).
|
||||
2. **Add the OAuth ID to the allowlist for [programmatic access](https://cloud.google.com/iap/docs/sharing-oauth-clients#programmatic_access) for the application.** Since Cloud Run is not yet a supported resource type in gcloud iap, you must allowlist the Client ID on the project.
|
||||
3. **Create a service account.** [Documentation](https://cloud.google.com/iam/docs/service-accounts-create#creating), [Cloud Console Link](https://console.cloud.google.com/iam-admin/serviceaccounts)
|
||||
4. **Add both the service account and users to the IAP Policy** in the "Security" tab of the Cloud Run service itself or via gcloud.
|
||||
5. **Grant all users and groups** who will access the MCP Server the necessary permissions to [impersonate the service account](https://cloud.google.com/docs/authentication/use-service-account-impersonation) (i.e., `roles/iam.serviceAccountTokenCreator`).
|
||||
6. **[Enable](https://console.cloud.google.com/apis/library/iamcredentials.googleapis.com) the IAM Credentials API** for your project.
|
||||
|
||||
### Example Configurations
|
||||
|
||||
#### Python MCP Server (Stdio)
|
||||
@@ -333,6 +356,21 @@ You can specify the authentication provider type using the `authProviderType` pr
|
||||
}
|
||||
```
|
||||
|
||||
### SSE MCP Server with SA Impersonation
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"myIapProtectedServer": {
|
||||
"url": "https://my-iap-service.run.app/sse",
|
||||
"authProviderType": "service_account_impersonation",
|
||||
"targetAudience": "YOUR_IAP_CLIENT_ID.apps.googleusercontent.com",
|
||||
"targetServiceAccount": "your-sa@your-project.iam.gserviceaccount.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Discovery Process Deep Dive
|
||||
|
||||
When the Gemini CLI starts, it performs MCP server discovery through the following detailed process:
|
||||
|
||||
+7
-7
@@ -8,7 +8,7 @@ This article outlines the specific terms and privacy policies applicable for dif
|
||||
|
||||
Your authentication method refers to the method you use to log into and access the Gemini CLI. There are four ways to authenticate:
|
||||
|
||||
- Logging in with your Google account to Gemini Code Assist for Individuals
|
||||
- Logging in with your Google account to Gemini Code Assist for individuals
|
||||
- Logging in with your Google account to Gemini Code Assist for Standard, or Enterprise Users
|
||||
- Using an API key with Gemini Developer
|
||||
- Using an API key with Vertex AI GenAI API
|
||||
@@ -17,18 +17,18 @@ For each of these four methods of authentication, different Terms of Service and
|
||||
|
||||
| Authentication | Account | Terms of Service | Privacy Notice |
|
||||
| :---------------------------- | :------------------ | :------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Gemini Code Assist via Google | Individual | [Google Terms of Service](https://policies.google.com/terms?hl=en-US) | [Gemini Code Assist Privacy Notice for Individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) |
|
||||
| Gemini Code Assist via Google | Individual | [Google Terms of Service](https://policies.google.com/terms?hl=en-US) | [Gemini Code Assist Privacy Notice for individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) |
|
||||
| Gemini Code Assist via Google | Standard/Enterprise | [Google Cloud Platform Terms of Service](https://cloud.google.com/terms) | [Gemini Code Assist Privacy Notice for Standard and Enterprise](https://cloud.google.com/gemini/docs/codeassist/security-privacy-compliance#standard_and_enterprise_data_protection_and_privacy) |
|
||||
| Gemini Developer API | Unpaid | [Gemini API Terms of Service - Unpaid Services](https://ai.google.dev/gemini-api/terms#unpaid-services) | [Google Privacy Policy](https://policies.google.com/privacy) |
|
||||
| Gemini Developer API | Paid | [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services) | [Google Privacy Policy](https://policies.google.com/privacy) |
|
||||
| Vertex AI Gen API | | [Google Cloud Platform Service Terms](https://cloud.google.com/terms/service-terms/) | [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice) |
|
||||
|
||||
## 1. If you have logged in with your Google account to Gemini Code Assist for Individuals
|
||||
## 1. If you have logged in with your Google account to Gemini Code Assist for individuals
|
||||
|
||||
For users who use their Google account to access [Gemini Code Assist for Individuals](https://developers.google.com/gemini-code-assist/docs/overview#supported-features-gca), these Terms of Service and Privacy Notice documents apply:
|
||||
For users who use their Google account to access [Gemini Code Assist for individuals](https://developers.google.com/gemini-code-assist/docs/overview#supported-features-gca), these Terms of Service and Privacy Notice documents apply:
|
||||
|
||||
- **Terms of Service:** Your use of the Gemini CLI is governed by the [Google Terms of Service](https://policies.google.com/terms?hl=en-US).
|
||||
- **Privacy Notice:** The collection and use of your data is described in the [Gemini Code Assist Privacy Notice for Individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals).
|
||||
- **Privacy Notice:** The collection and use of your data is described in the [Gemini Code Assist Privacy Notice for individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals).
|
||||
|
||||
## 2. If you have logged in with your Google account to Gemini Code Assist for Standard, or Enterprise Users
|
||||
|
||||
@@ -65,7 +65,7 @@ Whether your code, including prompts and answers, is used to train Google's mode
|
||||
|
||||
By default (if you have not opted out):
|
||||
|
||||
- **Google account with Gemini Code Assist for Individuals**: Yes. When you use your personal Google account, the [Gemini Code Assist Privacy Notice for Individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) applies. Under this notice,
|
||||
- **Google account with Gemini Code Assist for individuals**: Yes. When you use your personal Google account, the [Gemini Code Assist Privacy Notice for individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) applies. Under this notice,
|
||||
your **prompts, answers, and related code are collected** and may be used to improve Google's products, including for model training.
|
||||
- **Google account with Gemini Code Assist for Standard, or Enterprise**: No. For these accounts, your data is governed by the [Gemini Code Assist Privacy Notices](https://cloud.google.com/gemini/docs/codeassist/security-privacy-compliance#standard_and_enterprise_data_protection_and_privacy) terms, which treat your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
|
||||
- **Gemini API key via the Gemini Developer API**: Whether your code is collected or used depends on whether you are using an unpaid or paid service.
|
||||
@@ -81,7 +81,7 @@ The **Usage Statistics** setting is the single control for all optional data col
|
||||
|
||||
The data it collects depends on your account and authentication type:
|
||||
|
||||
- **Google account with Gemini Code Assist for Individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers, including code,** for model improvement.
|
||||
- **Google account with Gemini Code Assist for individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers, including code,** for model improvement.
|
||||
- **Google account with Gemini Code Assist for Standard, or Enterprise**: This setting only controls the collection of anonymous telemetry. Your prompts and answers, including code, are never collected, regardless of this setting.
|
||||
- **Gemini API key via the Gemini Developer API**:
|
||||
**Unpaid services**: When enabled, this setting allows Google to collect both anonymous telemetry (like commands run and performance metrics) and **your prompts and answers, including code,** for model improvement. When disabled we will use your data as described in [How Google Uses Your Data](https://ai.google.dev/gemini-api/terms#data-use-unpaid).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Trusted Folders
|
||||
|
||||
The Trusted Folders feature is a security setting that gives you control over which projects can use the full capabilities of the Gemini CLI. It prevents potentially malicious code from running by asking you to approve a folder before the CLI loads any project-specific configurations from it.
|
||||
|
||||
## Enabling the Feature
|
||||
|
||||
The Trusted Folders feature is **disabled by default**. To use it, you must first enable it in your settings.
|
||||
|
||||
Add the following to your user `settings.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"folderTrust": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works: The Trust Dialog
|
||||
|
||||
Once the feature is enabled, the first time you run the Gemini CLI from a folder, a dialog will automatically appear, prompting you to make a choice:
|
||||
|
||||
- **Trust folder**: Grants full trust to the current folder (e.g., `my-project`).
|
||||
- **Trust parent folder**: Grants trust to the parent directory (e.g., `safe-projects`), which automatically trusts all of its subdirectories as well. This is useful if you keep all your safe projects in one place.
|
||||
- **Don't trust**: Marks the folder as untrusted. The CLI will operate in a restricted "safe mode."
|
||||
|
||||
Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you will only be asked once per folder.
|
||||
|
||||
## Why Trust Matters: The Impact of an Untrusted Workspace
|
||||
|
||||
When a folder is **untrusted**, the Gemini CLI runs in a restricted "safe mode" to protect you. In this mode, the following features are disabled:
|
||||
|
||||
1. **Workspace Settings are Ignored**: The CLI will **not** load the `.gemini/settings.json` file from the project. This prevents the loading of custom tools and other potentially dangerous configurations.
|
||||
|
||||
2. **Environment Variables are Ignored**: The CLI will **not** load any `.env` files from the project.
|
||||
|
||||
3. **Extension Management is Restricted**: You **cannot install, update, or uninstall** extensions.
|
||||
|
||||
4. **Tool Auto-Acceptance is Disabled**: You will always be prompted before any tool is run, even if you have auto-acceptance enabled globally.
|
||||
|
||||
5. **Automatic Memory Loading is Disabled**: The CLI will not automatically load files into context from directories specified in local settings.
|
||||
|
||||
Granting trust to a folder unlocks the full functionality of the Gemini CLI for that workspace.
|
||||
|
||||
## Managing Your Trust Settings
|
||||
|
||||
If you need to change a decision or see all your settings, you have a couple of options:
|
||||
|
||||
- **Change the Current Folder's Trust**: Run the `/permissions` command from within the CLI. This will bring up the same interactive dialog, allowing you to change the trust level for the current folder.
|
||||
|
||||
- **View All Trust Rules**: To see a complete list of all your trusted and untrusted folder rules, you can inspect the contents of the `~/.gemini/trustedFolders.json` file in your home directory.
|
||||
|
||||
## The Trust Check Process (Advanced)
|
||||
|
||||
For advanced users, it's helpful to know the exact order of operations for how trust is determined:
|
||||
|
||||
1. **IDE Trust Signal**: If you are using the [IDE Integration](./ide-integration.md), the CLI first asks the IDE if the workspace is trusted. The IDE's response takes highest priority.
|
||||
|
||||
2. **Local Trust File**: If the IDE is not connected, the CLI checks the central `~/.gemini/trustedFolders.json` file.
|
||||
+8
-1
@@ -4,12 +4,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import esbuild from 'esbuild';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
let esbuild;
|
||||
try {
|
||||
esbuild = (await import('esbuild')).default;
|
||||
} catch (_error) {
|
||||
console.warn('esbuild not available, skipping bundle step');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Ink Library Screen Reader Guidance
|
||||
|
||||
When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience.
|
||||
|
||||
## General Principles
|
||||
|
||||
Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users.
|
||||
Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on <Box> and <Text> to provide semantic meaning to screen readers.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "context-example",
|
||||
"version": "1.0.0",
|
||||
"contextFileName": "GEMINI.md"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, type } from './test-helper.js';
|
||||
|
||||
describe('Interactive Mode', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should trigger chat compression with /compress command',
|
||||
async () => {
|
||||
await rig.setup('interactive-compress-test');
|
||||
|
||||
const { ptyProcess } = rig.runInteractive();
|
||||
|
||||
let fullOutput = '';
|
||||
ptyProcess.onData((data) => (fullOutput += data));
|
||||
|
||||
const authDialogAppeared = await rig.waitForText(
|
||||
'How would you like to authenticate',
|
||||
5000,
|
||||
);
|
||||
|
||||
// select the second option if auth dialog come's up
|
||||
if (authDialogAppeared) {
|
||||
ptyProcess.write('2');
|
||||
}
|
||||
|
||||
// Wait for the app to be ready
|
||||
const isReady = await rig.waitForText('Type your message', 15000);
|
||||
expect(
|
||||
isReady,
|
||||
'CLI did not start up in interactive mode correctly',
|
||||
).toBe(true);
|
||||
|
||||
const longPrompt =
|
||||
'Dont do anything except returning a 1000 token long paragragh with the <name of the scientist who discovered theory of relativity> at the end to indicate end of response. This is a moderately long sentence.';
|
||||
|
||||
await type(ptyProcess, longPrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
await rig.waitForText('einstein', 25000);
|
||||
|
||||
await type(ptyProcess, '/compress');
|
||||
// A small delay to allow React to re-render the command list.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const foundEvent = await rig.waitForTelemetryEvent(
|
||||
'chat_compression',
|
||||
90000,
|
||||
);
|
||||
expect(foundEvent, 'chat_compression telemetry event was not found').toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should handle compression failure on token inflation',
|
||||
async () => {
|
||||
await rig.setup('interactive-compress-test');
|
||||
|
||||
const { ptyProcess } = rig.runInteractive();
|
||||
|
||||
let fullOutput = '';
|
||||
ptyProcess.onData((data) => (fullOutput += data));
|
||||
|
||||
const authDialogAppeared = await rig.waitForText(
|
||||
'How would you like to authenticate',
|
||||
5000,
|
||||
);
|
||||
|
||||
// select the second option if auth dialog come's up
|
||||
if (authDialogAppeared) {
|
||||
ptyProcess.write('2');
|
||||
}
|
||||
|
||||
// Wait for the app to be ready
|
||||
const isReady = await rig.waitForText('Type your message', 25000);
|
||||
expect(
|
||||
isReady,
|
||||
'CLI did not start up in interactive mode correctly',
|
||||
).toBe(true);
|
||||
|
||||
await type(ptyProcess, '/compress');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const foundEvent = await rig.waitForTelemetryEvent(
|
||||
'chat_compression',
|
||||
90000,
|
||||
);
|
||||
expect(foundEvent).toBe(true);
|
||||
|
||||
const compressionFailed = await rig.waitForText(
|
||||
'compression was not beneficial',
|
||||
25000,
|
||||
);
|
||||
|
||||
expect(compressionFailed).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
describe('Ctrl+C exit', () => {
|
||||
// (#9782) Temporarily disabling on windows because it is failing on main and every
|
||||
// PR, which is potentially hiding other failures
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should exit gracefully on second Ctrl+C',
|
||||
async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should exit gracefully on second Ctrl+C');
|
||||
|
||||
const { ptyProcess, promise } = rig.runInteractive();
|
||||
|
||||
let output = '';
|
||||
ptyProcess.onData((data) => {
|
||||
output += data;
|
||||
});
|
||||
|
||||
// Wait for the app to be ready by looking for the initial prompt indicator
|
||||
await rig.poll(() => output.includes('▶'), 5000, 100);
|
||||
|
||||
// Send first Ctrl+C
|
||||
ptyProcess.write(String.fromCharCode(3));
|
||||
|
||||
// Wait for the exit prompt
|
||||
await rig.poll(
|
||||
() => output.includes('Press Ctrl+C again to exit'),
|
||||
1500,
|
||||
50,
|
||||
);
|
||||
|
||||
// Send second Ctrl+C
|
||||
ptyProcess.write(String.fromCharCode(3));
|
||||
|
||||
const result = await promise;
|
||||
|
||||
// Expect a graceful exit (code 0)
|
||||
expect(
|
||||
result.exitCode,
|
||||
`Process exited with code ${result.exitCode}. Output: ${result.output}`,
|
||||
).toBe(0);
|
||||
|
||||
// Check that the quitting message is displayed
|
||||
const quittingMessage = 'Agent powering down. Goodbye!';
|
||||
// The regex below is intentionally matching the ESC control character (\x1b)
|
||||
// to strip ANSI color codes from the terminal output.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
expect(cleanOutput).toContain(quittingMessage);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should exit gracefully on second Ctrl+C when calling a tool',
|
||||
async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup(
|
||||
'should exit gracefully on second Ctrl+C when calling a tool',
|
||||
);
|
||||
|
||||
const childProcessFile = 'child_process_file.txt';
|
||||
rig.createFile(
|
||||
'wait.js',
|
||||
`setTimeout(() => require('fs').writeFileSync('${childProcessFile}', 'done'), 5000)`,
|
||||
);
|
||||
|
||||
const { ptyProcess, promise } = rig.runInteractive();
|
||||
|
||||
let output = '';
|
||||
ptyProcess.onData((data) => {
|
||||
output += data;
|
||||
});
|
||||
|
||||
// Wait for the app to be ready by looking for the initial prompt indicator
|
||||
await rig.poll(() => output.includes('▶'), 5000, 100);
|
||||
|
||||
ptyProcess.write('use the tool to run "node -e wait.js"\n');
|
||||
|
||||
await rig.poll(() => output.includes('Shell'), 5000, 100);
|
||||
|
||||
// Send first Ctrl+C
|
||||
ptyProcess.write(String.fromCharCode(3));
|
||||
|
||||
// Wait for the exit prompt
|
||||
await rig.poll(
|
||||
() => output.includes('Press Ctrl+C again to exit'),
|
||||
1500,
|
||||
50,
|
||||
);
|
||||
|
||||
// Send second Ctrl+C
|
||||
ptyProcess.write(String.fromCharCode(3));
|
||||
|
||||
const result = await promise;
|
||||
|
||||
// Expect a graceful exit (code 0)
|
||||
expect(
|
||||
result.exitCode,
|
||||
`Process exited with code ${result.exitCode}. Output: ${result.output}`,
|
||||
).toBe(0);
|
||||
|
||||
// Check that the quitting message is displayed
|
||||
const quittingMessage = 'Agent powering down. Goodbye!';
|
||||
// The regex below is intentionally matching the ESC control character (\x1b)
|
||||
// to strip ANSI color codes from the terminal output.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
expect(cleanOutput).toContain(quittingMessage);
|
||||
|
||||
// Check that the child process was terminated and did not create the file.
|
||||
const childProcessFileExists = fs.existsSync(
|
||||
path.join(rig.testDir!, childProcessFile),
|
||||
);
|
||||
expect(
|
||||
childProcessFileExists,
|
||||
'Child process file should not exist',
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, test } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const extension = `{
|
||||
"name": "test-extension",
|
||||
"version": "0.0.1"
|
||||
}`;
|
||||
|
||||
const extensionUpdate = `{
|
||||
"name": "test-extension",
|
||||
"version": "0.0.2"
|
||||
}`;
|
||||
|
||||
test('installs a local extension, verifies a command, and updates it', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('extension install test');
|
||||
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
|
||||
writeFileSync(testServerPath, extension);
|
||||
try {
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
const result = await rig.runCommand(
|
||||
['extensions', 'install', `${rig.testDir!}`],
|
||||
{ stdin: 'y\n' },
|
||||
);
|
||||
expect(result).toContain('test-extension');
|
||||
|
||||
const listResult = await rig.runCommand(['extensions', 'list']);
|
||||
expect(listResult).toContain('test-extension');
|
||||
writeFileSync(testServerPath, extensionUpdate);
|
||||
const updateResult = await rig.runCommand([
|
||||
'extensions',
|
||||
'update',
|
||||
`test-extension`,
|
||||
]);
|
||||
expect(updateResult).toContain('0.0.2');
|
||||
|
||||
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
|
||||
|
||||
await rig.cleanup();
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, type, printDebugInfo } from './test-helper.js';
|
||||
|
||||
describe('Interactive file system', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should perform a read-then-write sequence',
|
||||
async () => {
|
||||
const fileName = 'version.txt';
|
||||
await rig.setup('interactive-read-then-write');
|
||||
rig.createFile(fileName, '1.0.0');
|
||||
|
||||
const { ptyProcess } = rig.runInteractive();
|
||||
|
||||
const authDialogAppeared = await rig.waitForText(
|
||||
'How would you like to authenticate',
|
||||
5000,
|
||||
);
|
||||
|
||||
// select the second option if auth dialog come's up
|
||||
if (authDialogAppeared) {
|
||||
ptyProcess.write('2');
|
||||
}
|
||||
|
||||
// Wait for the app to be ready
|
||||
const isReady = await rig.waitForText('Type your message', 15000);
|
||||
expect(
|
||||
isReady,
|
||||
'CLI did not start up in interactive mode correctly',
|
||||
).toBe(true);
|
||||
|
||||
// Step 1: Read the file
|
||||
const readPrompt = `Read the version from ${fileName}`;
|
||||
await type(ptyProcess, readPrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const readCall = await rig.waitForToolCall('read_file', 30000);
|
||||
expect(readCall, 'Expected to find a read_file tool call').toBe(true);
|
||||
|
||||
const containsExpectedVersion = await rig.waitForText('1.0.0', 15000);
|
||||
expect(
|
||||
containsExpectedVersion,
|
||||
'Expected to see version "1.0.0" in output',
|
||||
).toBe(true);
|
||||
|
||||
// Step 2: Write the file
|
||||
const writePrompt = `now change the version to 1.0.1 in the file`;
|
||||
await type(ptyProcess, writePrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const toolCall = await rig.waitForAnyToolCall(
|
||||
['write_file', 'replace'],
|
||||
30000,
|
||||
);
|
||||
|
||||
if (!toolCall) {
|
||||
printDebugInfo(rig, rig._interactiveOutput, {
|
||||
toolCall,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
toolCall,
|
||||
'Expected to find a write_file or replace tool call',
|
||||
).toBe(true);
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe('1.0.1');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe('file-system', () => {
|
||||
@@ -90,4 +92,169 @@ describe('file-system', () => {
|
||||
console.log('File written successfully with hello message.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly handle file paths with spaces', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should correctly handle file paths with spaces');
|
||||
const fileName = 'my test file.txt';
|
||||
|
||||
const result = await rig.run(`write "hello" to "${fileName}"`);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('write_file');
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a write_file tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('should perform a read-then-write sequence', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should perform a read-then-write sequence');
|
||||
const fileName = 'version.txt';
|
||||
rig.createFile(fileName, '1.0.0');
|
||||
|
||||
const prompt = `Read the version from ${fileName} and write the next version 1.0.1 back to the file.`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const readCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'read_file',
|
||||
);
|
||||
const writeCall = toolLogs.find(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'write_file' ||
|
||||
log.toolRequest.name === 'replace',
|
||||
);
|
||||
|
||||
if (!readCall || !writeCall) {
|
||||
printDebugInfo(rig, result, { readCall, writeCall });
|
||||
}
|
||||
|
||||
expect(readCall, 'Expected to find a read_file tool call').toBeDefined();
|
||||
expect(
|
||||
writeCall,
|
||||
'Expected to find a write_file or replace tool call',
|
||||
).toBeDefined();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe('1.0.1');
|
||||
});
|
||||
|
||||
it.skip('should replace multiple instances of a string', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should replace multiple instances of a string');
|
||||
const fileName = 'ambiguous.txt';
|
||||
const fileContent = 'Hey there, \ntest line\ntest line';
|
||||
const expectedContent = 'Hey there, \nnew line\nnew line';
|
||||
rig.createFile(fileName, fileContent);
|
||||
|
||||
const result = await rig.run(
|
||||
`replace "test line" with "new line" in ${fileName}`,
|
||||
);
|
||||
|
||||
const foundToolCall = await rig.waitForAnyToolCall([
|
||||
'replace',
|
||||
'write_file',
|
||||
]);
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a replace or write_file tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const successfulEdit = toolLogs.some(
|
||||
(log) =>
|
||||
(log.toolRequest.name === 'replace' ||
|
||||
log.toolRequest.name === 'write_file') &&
|
||||
log.toolRequest.success,
|
||||
);
|
||||
if (!successfulEdit) {
|
||||
console.error(
|
||||
'Expected a successful edit tool call, but none was found.',
|
||||
);
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(successfulEdit, 'Expected a successful edit tool call').toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe(expectedContent);
|
||||
});
|
||||
|
||||
it('should fail safely when trying to edit a non-existent file', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup(
|
||||
'should fail safely when trying to edit a non-existent file',
|
||||
);
|
||||
const fileName = 'non_existent.txt';
|
||||
|
||||
const result = await rig.run(`In ${fileName}, replace "a" with "b"`);
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const readAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'read_file',
|
||||
);
|
||||
const writeAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'write_file',
|
||||
);
|
||||
const successfulReplace = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'replace' && log.toolRequest.success,
|
||||
);
|
||||
|
||||
// The model can either investigate (and fail) or do nothing.
|
||||
// If it chose to investigate by reading, that read must have failed.
|
||||
if (readAttempt && readAttempt.toolRequest.success) {
|
||||
console.error(
|
||||
'A read_file attempt succeeded for a non-existent file when it should have failed.',
|
||||
);
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
if (readAttempt) {
|
||||
expect(
|
||||
readAttempt.toolRequest.success,
|
||||
'If model tries to read the file, that attempt must fail',
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// CRITICAL: Verify that no matter what the model did, it never successfully
|
||||
// wrote or replaced anything.
|
||||
if (writeAttempt) {
|
||||
console.error(
|
||||
'A write_file attempt was made when no file should be written.',
|
||||
);
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
writeAttempt,
|
||||
'write_file should not have been called',
|
||||
).toBeUndefined();
|
||||
|
||||
if (successfulReplace) {
|
||||
console.error('A successful replace occurred when it should not have.');
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
successfulReplace,
|
||||
'A successful replace should not have occurred',
|
||||
).toBeUndefined();
|
||||
|
||||
// Final verification: ensure the file was not created.
|
||||
const filePath = path.join(rig.testDir!, fileName);
|
||||
const fileExists = existsSync(filePath);
|
||||
expect(fileExists, 'The non-existent file should not be created').toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as net from 'node:net';
|
||||
import * as child_process from 'node:child_process';
|
||||
import { IdeClient } from '../packages/core/src/ide/ide-client.js';
|
||||
|
||||
import { TestMcpServer } from './test-mcp-server.js';
|
||||
|
||||
describe.skip('IdeClient', () => {
|
||||
it('reads port from file and connects', async () => {
|
||||
const server = new TestMcpServer();
|
||||
const port = await server.start();
|
||||
const pid = process.pid;
|
||||
const portFile = path.join(os.tmpdir(), `gemini-ide-server-${pid}.json`);
|
||||
fs.writeFileSync(portFile, JSON.stringify({ port }));
|
||||
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = process.cwd();
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus()).toEqual({
|
||||
status: 'connected',
|
||||
details: undefined,
|
||||
});
|
||||
|
||||
fs.unlinkSync(portFile);
|
||||
await server.stop();
|
||||
delete process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||
});
|
||||
});
|
||||
|
||||
const getFreePort = (): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, () => {
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
server.close(() => {
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('IdeClient fallback connection logic', () => {
|
||||
let server: TestMcpServer;
|
||||
let envPort: number;
|
||||
let pid: number;
|
||||
let portFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
pid = process.pid;
|
||||
portFile = path.join(os.tmpdir(), `gemini-ide-server-${pid}.json`);
|
||||
server = new TestMcpServer();
|
||||
envPort = await server.start();
|
||||
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = String(envPort);
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = process.cwd();
|
||||
// Reset instance
|
||||
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
|
||||
undefined;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.stop();
|
||||
delete process.env['GEMINI_CLI_IDE_SERVER_PORT'];
|
||||
delete process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||
if (fs.existsSync(portFile)) {
|
||||
fs.unlinkSync(portFile);
|
||||
}
|
||||
});
|
||||
|
||||
it('connects using env var when port file does not exist', async () => {
|
||||
// Ensure port file doesn't exist
|
||||
if (fs.existsSync(portFile)) {
|
||||
fs.unlinkSync(portFile);
|
||||
}
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus()).toEqual({
|
||||
status: 'connected',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to env var when connection with port from file fails', async () => {
|
||||
const filePort = await getFreePort();
|
||||
// Write port file with a port that is not listening
|
||||
fs.writeFileSync(portFile, JSON.stringify({ port: filePort }));
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus()).toEqual({
|
||||
status: 'connected',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('getIdeProcessId', () => {
|
||||
let child: child_process.ChildProcess;
|
||||
|
||||
afterEach(() => {
|
||||
if (child) {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return the pid of the parent process', async () => {
|
||||
// We need to spawn a child process that will run the test
|
||||
// so that we can check that getIdeProcessId returns the pid of the parent
|
||||
const parentPid = process.pid;
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
child = child_process.spawn(
|
||||
'node',
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
const { getIdeProcessId } = require('../packages/core/src/ide/process-utils.js');
|
||||
getIdeProcessId().then(pid => console.log(pid));
|
||||
`,
|
||||
],
|
||||
{
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
let out = '';
|
||||
child.stdout?.on('data', (data) => {
|
||||
out += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(out.trim());
|
||||
} else {
|
||||
reject(new Error(`Child process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(parseInt(output, 10)).toBe(parentPid);
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('IdeClient with proxy', () => {
|
||||
let mcpServer: TestMcpServer;
|
||||
let proxyServer: net.Server;
|
||||
let mcpServerPort: number;
|
||||
let proxyServerPort: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
mcpServer = new TestMcpServer();
|
||||
mcpServerPort = await mcpServer.start();
|
||||
|
||||
proxyServer = net.createServer().listen();
|
||||
proxyServerPort = (proxyServer.address() as net.AddressInfo).port;
|
||||
|
||||
vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', String(mcpServerPort));
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', process.cwd());
|
||||
|
||||
// Reset instance
|
||||
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
|
||||
undefined;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
(await IdeClient.getInstance()).disconnect();
|
||||
await mcpServer.stop();
|
||||
proxyServer.close();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should connect to IDE server when HTTP_PROXY, HTTPS_PROXY and NO_PROXY are set', async () => {
|
||||
vi.stubEnv('HTTP_PROXY', `http://localhost:${proxyServerPort}`);
|
||||
vi.stubEnv('HTTPS_PROXY', `http://localhost:${proxyServerPort}`);
|
||||
vi.stubEnv('NO_PROXY', 'example.com,127.0.0.1,::1');
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(ideClient.getConnectionStatus()).toEqual({
|
||||
status: 'connected',
|
||||
details: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,26 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* This test verifies we can match maximum schema depth errors from Gemini
|
||||
* and then detect and warn about the potential tools that caused the error.
|
||||
* This test verifies we can provide MCP tools with recursive input schemas
|
||||
* (in JSON, using the $ref keyword) and both the GenAI SDK and the Gemini
|
||||
* API calls succeed. Note that prior to
|
||||
* https://github.com/googleapis/js-genai/commit/36f6350705ecafc47eaea3f3eecbcc69512edab7#diff-fdde9372aec859322b7c5a5efe467e0ad25a57210c7229724586ee90ea4f5a30
|
||||
* the Gemini API call would fail for such tools because the schema was
|
||||
* passed not as a JSON string but using the Gemini API's tool parameter
|
||||
* schema object which has stricter typing and recursion restrictions.
|
||||
* If this test fails, it's likely because either the GenAI SDK or Gemini API
|
||||
* has become more restrictive about the type of tool parameter schemas that
|
||||
* are accepted. If this occurs: Gemini CLI previously attempted to detect
|
||||
* such tools and proactively remove them from the set of tools provided in
|
||||
* the Gemini API call (as FunctionDeclaration objects). It may be appropriate
|
||||
* to resurrect that behavior but note that it's difficult to keep the
|
||||
* GCLI filters in sync with the Gemini API restrictions and behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, beforeAll, expect } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
// Create a minimal MCP server that doesn't require external dependencies
|
||||
// This implements the MCP protocol directly using Node.js built-ins
|
||||
@@ -180,15 +192,14 @@ describe('mcp server with cyclic tool schema is detected', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should error and suggest disabling the cyclic tool', async () => {
|
||||
// Just run any command to trigger the schema depth error.
|
||||
// If this test starts failing, check `isSchemaDepthError` from
|
||||
// geminiChat.ts to see if it needs to be updated.
|
||||
// Or, possibly it could mean that gemini has fixed the issue.
|
||||
const output = await rig.run('hello');
|
||||
it('mcp tool list should include tool with cyclic tool schema', async () => {
|
||||
const tool_list_output = await rig.run('/mcp list');
|
||||
expect(tool_list_output).toContain('tool_with_cyclic_schema');
|
||||
});
|
||||
|
||||
expect(output).toMatch(
|
||||
/Skipping tool 'tool_with_cyclic_schema' from MCP server 'cyclic-schema-server' because it has missing types in its parameter schema/,
|
||||
);
|
||||
it('gemini api call should be successful with cyclic mcp tool schema', async () => {
|
||||
// Run any command and verify that we get a non-error response from
|
||||
// the Gemini API.
|
||||
await rig.run('hello');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,4 +91,98 @@ describe('replace', () => {
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe(expectedContent);
|
||||
});
|
||||
|
||||
it('should fail safely when old_string is not found', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should fail safely when old_string is not found');
|
||||
const fileName = 'no_match.txt';
|
||||
const fileContent = 'hello world';
|
||||
rig.createFile(fileName, fileContent);
|
||||
|
||||
const prompt = `replace "goodbye" with "farewell" in ${fileName}`;
|
||||
await rig.run(prompt);
|
||||
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const replaceAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'replace',
|
||||
);
|
||||
const readAttempt = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'read_file',
|
||||
);
|
||||
|
||||
// VERIFY: The model must have at least tried to read the file or perform a replace.
|
||||
expect(
|
||||
readAttempt || replaceAttempt,
|
||||
'Expected model to attempt a read_file or replace',
|
||||
).toBeDefined();
|
||||
|
||||
// If the model tried to replace, that specific attempt must have failed.
|
||||
if (replaceAttempt) {
|
||||
if (replaceAttempt.toolRequest.success) {
|
||||
console.error(
|
||||
'The replace tool succeeded when it was expected to fail',
|
||||
);
|
||||
console.error('Tool call args:', replaceAttempt.toolRequest.args);
|
||||
}
|
||||
expect(
|
||||
replaceAttempt.toolRequest.success,
|
||||
'If replace is called, it must fail',
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// CRITICAL: The final content of the file must be unchanged.
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe(fileContent);
|
||||
});
|
||||
|
||||
it('should insert a multi-line block of text', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should insert a multi-line block of text');
|
||||
const fileName = 'insert_block.js';
|
||||
const originalContent = 'function hello() {\n // INSERT_CODE_HERE\n}';
|
||||
const newBlock = "console.log('hello');\n console.log('world');";
|
||||
const expectedContent = `function hello() {\n ${newBlock}\n}`;
|
||||
rig.createFile(fileName, originalContent);
|
||||
|
||||
const prompt = `In ${fileName}, replace "// INSERT_CODE_HERE" with:\n${newBlock}`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('replace');
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
|
||||
expect(newFileContent.replace(/\r\n/g, '\n')).toBe(
|
||||
expectedContent.replace(/\r\n/g, '\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete a block of text', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should delete a block of text');
|
||||
const fileName = 'delete_block.txt';
|
||||
const blockToDelete =
|
||||
'## DELETE THIS ##\nThis is a block of text to delete.\n## END DELETE ##';
|
||||
const originalContent = `Hello\n${blockToDelete}\nWorld`;
|
||||
const expectedContent = 'Hello\nWorld';
|
||||
rig.createFile(fileName, originalContent);
|
||||
|
||||
const prompt = `In ${fileName}, delete the entire block from "## DELETE THIS ##" to "## END DELETE ##" including the markers.`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('replace');
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
|
||||
expect(newFileContent).toBe(expectedContent);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,4 +67,64 @@ describe('run_shell_command', () => {
|
||||
// Validate model output - will throw if no output, warn if missing expected content
|
||||
validateModelOutput(result, 'test-stdin', 'Shell command stdin test');
|
||||
});
|
||||
|
||||
it('should propagate environment variables to the child process', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should propagate environment variables');
|
||||
|
||||
const varName = 'GEMINI_CLI_TEST_VAR';
|
||||
const varValue = `test-value-${Math.random().toString(36).substring(7)}`;
|
||||
process.env[varName] = varValue;
|
||||
|
||||
try {
|
||||
const prompt = `Use echo to learn the value of the environment variable named ${varName} and tell me what it is.`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command');
|
||||
|
||||
if (!foundToolCall || !result.includes(varValue)) {
|
||||
printDebugInfo(rig, result, {
|
||||
'Found tool call': foundToolCall,
|
||||
'Contains varValue': result.includes(varValue),
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
validateModelOutput(result, varValue, 'Env var propagation test');
|
||||
expect(result).toContain(varValue);
|
||||
} finally {
|
||||
delete process.env[varName];
|
||||
}
|
||||
});
|
||||
|
||||
it('should run a platform-specific file listing command', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should run platform-specific file listing');
|
||||
const fileName = `test-file-${Math.random().toString(36).substring(7)}.txt`;
|
||||
rig.createFile(fileName, 'test content');
|
||||
|
||||
const prompt = `Run a shell command to list the files in the current directory and tell me what they are.`;
|
||||
const result = await rig.run(prompt);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('run_shell_command');
|
||||
|
||||
// Debugging info
|
||||
if (!foundToolCall || !result.includes(fileName)) {
|
||||
printDebugInfo(rig, result, {
|
||||
'Found tool call': foundToolCall,
|
||||
'Contains fileName': result.includes(fileName),
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a run_shell_command tool call',
|
||||
).toBeTruthy();
|
||||
|
||||
validateModelOutput(result, fileName, 'Platform-specific listing test');
|
||||
expect(result).toContain(fileName);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { ShellExecutionService } from '../packages/core/src/services/shellExecutionService.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('ShellExecutionService programmatic integration tests', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a dedicated directory for this test suite to avoid conflicts.
|
||||
testDir = path.join(
|
||||
process.env['INTEGRATION_TEST_FILE_DIR']!,
|
||||
'shell-service-tests',
|
||||
);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('should execute a simple cross-platform command (echo)', async () => {
|
||||
const command = 'echo "hello from the service"';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Output can vary slightly between shells (e.g., quotes), so check for inclusion.
|
||||
expect(result.output).toContain('hello from the service');
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'win32')(
|
||||
'should execute "dir" on Windows',
|
||||
async () => {
|
||||
const testFile = 'test-file-windows.txt';
|
||||
await fs.writeFile(path.join(testDir, testFile), 'windows test');
|
||||
|
||||
const command = 'dir';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain(testFile);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should execute "ls -l" on Unix',
|
||||
async () => {
|
||||
const testFile = 'test-file-unix.txt';
|
||||
await fs.writeFile(path.join(testDir, testFile), 'unix test');
|
||||
|
||||
const command = 'ls -l';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain(testFile);
|
||||
},
|
||||
);
|
||||
|
||||
it('should abort a running process', async () => {
|
||||
// A command that runs for a bit.
|
||||
const command = 'node -e "setTimeout(() => {}, 20000)"';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
// Abort shortly after starting
|
||||
setTimeout(() => abortController.abort(), 50);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
// For debugging the flaky test.
|
||||
console.log('Abort test result:', result);
|
||||
|
||||
expect(result.aborted).toBe(true);
|
||||
// A clean exit is exitCode 0 and no signal. If the process was truly
|
||||
// aborted, it should not have exited cleanly.
|
||||
const exitedCleanly = result.exitCode === 0 && result.signal === null;
|
||||
expect(exitedCleanly, 'Process should not have exited cleanly').toBe(false);
|
||||
});
|
||||
|
||||
it('should propagate environment variables to the child process', async () => {
|
||||
const varName = 'GEMINI_CLI_TEST_VAR';
|
||||
const varValue = `test-value`;
|
||||
process.env[varName] = varValue;
|
||||
|
||||
try {
|
||||
const command =
|
||||
process.platform === 'win32' ? `echo %${varName}%` : `echo $${varName}`;
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
command,
|
||||
testDir,
|
||||
onOutputEvent,
|
||||
abortController.signal,
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain(varValue);
|
||||
} finally {
|
||||
// Clean up the env var to prevent side-effects on other tests.
|
||||
delete process.env[varName];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
describe('telemetry', () => {
|
||||
it('should emit a metric and a log event', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('should emit a metric and a log event');
|
||||
|
||||
// Run a simple command that should trigger telemetry
|
||||
await rig.run('just saying hi');
|
||||
|
||||
// Verify that a user_prompt event was logged
|
||||
const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt');
|
||||
expect(hasUserPromptEvent).toBe(true);
|
||||
|
||||
// Verify that a cli_command_count metric was emitted
|
||||
const cliCommandCountMetric = rig.readMetric('session.count');
|
||||
expect(cliCommandCountMetric).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+236
-112
@@ -9,7 +9,10 @@ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { env } from 'node:process';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../packages/core/src/config/models.js';
|
||||
import fs from 'node:fs';
|
||||
import * as pty from '@lydell/node-pty';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -110,11 +113,38 @@ export function validateModelOutput(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Simulates typing a string one character at a time to avoid paste detection.
|
||||
export async function type(ptyProcess: pty.IPty, text: string) {
|
||||
const delay = 5;
|
||||
for (const char of text) {
|
||||
ptyProcess.write(char);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedLog {
|
||||
attributes?: {
|
||||
'event.name'?: string;
|
||||
function_name?: string;
|
||||
function_args?: string;
|
||||
success?: boolean;
|
||||
duration_ms?: number;
|
||||
};
|
||||
scopeMetrics?: {
|
||||
metrics: {
|
||||
descriptor: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export class TestRig {
|
||||
bundlePath: string;
|
||||
testDir: string | null;
|
||||
testName?: string;
|
||||
_lastRunStdout?: string;
|
||||
_interactiveOutput = '';
|
||||
|
||||
constructor() {
|
||||
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
|
||||
@@ -151,6 +181,7 @@ export class TestRig {
|
||||
otlpEndpoint: '',
|
||||
outfile: telemetryPath,
|
||||
},
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false,
|
||||
...options.settings, // Allow tests to override/add settings
|
||||
};
|
||||
@@ -175,13 +206,32 @@ export class TestRig {
|
||||
execSync('sync', { cwd: this.testDir! });
|
||||
}
|
||||
|
||||
/**
|
||||
* The command and args to use to invoke Gemini CLI. Allows us to switch
|
||||
* between using the bundled gemini.js (the default) and using the installed
|
||||
* 'gemini' (used to verify npm bundles).
|
||||
*/
|
||||
private _getCommandAndArgs(extraInitialArgs: string[] = []): {
|
||||
command: string;
|
||||
initialArgs: string[];
|
||||
} {
|
||||
const isNpmReleaseTest =
|
||||
process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true';
|
||||
const command = isNpmReleaseTest ? 'gemini' : 'node';
|
||||
const initialArgs = isNpmReleaseTest
|
||||
? extraInitialArgs
|
||||
: [this.bundlePath, ...extraInitialArgs];
|
||||
return { command, initialArgs };
|
||||
}
|
||||
|
||||
run(
|
||||
promptOrOptions:
|
||||
| string
|
||||
| { prompt?: string; stdin?: string; stdinDoesNotEnd?: boolean },
|
||||
...args: string[]
|
||||
): Promise<string> {
|
||||
const commandArgs = [this.bundlePath, '--yolo'];
|
||||
const { command, initialArgs } = this._getCommandAndArgs(['--yolo']);
|
||||
const commandArgs = [...initialArgs];
|
||||
const execOptions: {
|
||||
cwd: string;
|
||||
encoding: 'utf-8';
|
||||
@@ -207,9 +257,10 @@ export class TestRig {
|
||||
|
||||
commandArgs.push(...args);
|
||||
|
||||
const child = spawn('node', commandArgs, {
|
||||
const child = spawn(command, commandArgs, {
|
||||
cwd: this.testDir!,
|
||||
stdio: 'pipe',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
@@ -306,6 +357,58 @@ export class TestRig {
|
||||
return promise;
|
||||
}
|
||||
|
||||
runCommand(
|
||||
args: string[],
|
||||
options: { stdin?: string } = {},
|
||||
): Promise<string> {
|
||||
const { command, initialArgs } = this._getCommandAndArgs();
|
||||
const commandArgs = [...initialArgs, ...args];
|
||||
|
||||
const child = spawn(command, commandArgs, {
|
||||
cwd: this.testDir!,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
if (options.stdin) {
|
||||
child.stdin!.write(options.stdin);
|
||||
child.stdin!.end();
|
||||
}
|
||||
|
||||
child.stdout!.on('data', (data: Buffer) => {
|
||||
stdout += data;
|
||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||
process.stdout.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr!.on('data', (data: Buffer) => {
|
||||
stderr += data;
|
||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||
process.stderr.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
child.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
this._lastRunStdout = stdout;
|
||||
let result = stdout;
|
||||
if (stderr) {
|
||||
result += `\n\nStdErr:\n${stderr}`;
|
||||
}
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
readFile(fileName: string) {
|
||||
const filePath = join(this.testDir!, fileName);
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
@@ -363,37 +466,12 @@ export class TestRig {
|
||||
|
||||
return this.poll(
|
||||
() => {
|
||||
const logFilePath = join(this.testDir!, 'telemetry.log');
|
||||
|
||||
if (!logFilePath || !fs.existsSync(logFilePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
const jsonObjects = content
|
||||
.split(/}\n{/)
|
||||
.map((obj, index, array) => {
|
||||
// Add back the braces we removed during split
|
||||
if (index > 0) obj = '{' + obj;
|
||||
if (index < array.length - 1) obj = obj + '}';
|
||||
return obj.trim();
|
||||
})
|
||||
.filter((obj) => obj);
|
||||
|
||||
for (const jsonStr of jsonObjects) {
|
||||
try {
|
||||
const logData = JSON.parse(jsonStr);
|
||||
if (
|
||||
logData.attributes &&
|
||||
logData.attributes['event.name'] === `gemini_cli.${eventName}`
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return false;
|
||||
const logs = this._readAndParseTelemetryLog();
|
||||
return logs.some(
|
||||
(logData) =>
|
||||
logData.attributes &&
|
||||
logData.attributes['event.name'] === `gemini_cli.${eventName}`,
|
||||
);
|
||||
},
|
||||
timeout,
|
||||
100,
|
||||
@@ -590,6 +668,45 @@ export class TestRig {
|
||||
return logs;
|
||||
}
|
||||
|
||||
private _readAndParseTelemetryLog(): ParsedLog[] {
|
||||
// Telemetry is always written to the test directory
|
||||
const logFilePath = join(this.testDir!, 'telemetry.log');
|
||||
|
||||
if (!logFilePath || !fs.existsSync(logFilePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
|
||||
// Split the content into individual JSON objects
|
||||
// They are separated by "}\n{"
|
||||
const jsonObjects = content
|
||||
.split(/}\n{/)
|
||||
.map((obj, index, array) => {
|
||||
// Add back the braces we removed during split
|
||||
if (index > 0) obj = '{' + obj;
|
||||
if (index < array.length - 1) obj = obj + '}';
|
||||
return obj.trim();
|
||||
})
|
||||
.filter((obj) => obj);
|
||||
|
||||
const logs: ParsedLog[] = [];
|
||||
|
||||
for (const jsonStr of jsonObjects) {
|
||||
try {
|
||||
const logData = JSON.parse(jsonStr);
|
||||
logs.push(logData);
|
||||
} catch (e) {
|
||||
// Skip objects that aren't valid JSON
|
||||
if (env.VERBOSE === 'true') {
|
||||
console.error('Failed to parse telemetry object:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
readToolLogs() {
|
||||
// For Podman, first check if telemetry file exists and has content
|
||||
// If not, fall back to parsing from stdout
|
||||
@@ -619,33 +736,7 @@ export class TestRig {
|
||||
}
|
||||
}
|
||||
|
||||
// Telemetry is always written to the test directory
|
||||
const logFilePath = join(this.testDir!, 'telemetry.log');
|
||||
|
||||
if (!logFilePath) {
|
||||
console.warn(`TELEMETRY_LOG_FILE environment variable not set`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if file exists, if not return empty array (file might not be created yet)
|
||||
if (!fs.existsSync(logFilePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
|
||||
// Split the content into individual JSON objects
|
||||
// They are separated by "}\n{"
|
||||
const jsonObjects = content
|
||||
.split(/}\n{/)
|
||||
.map((obj, index, array) => {
|
||||
// Add back the braces we removed during split
|
||||
if (index > 0) obj = '{' + obj;
|
||||
if (index < array.length - 1) obj = obj + '}';
|
||||
return obj.trim();
|
||||
})
|
||||
.filter((obj) => obj);
|
||||
|
||||
const parsedLogs = this._readAndParseTelemetryLog();
|
||||
const logs: {
|
||||
toolRequest: {
|
||||
name: string;
|
||||
@@ -655,29 +746,21 @@ export class TestRig {
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
for (const jsonStr of jsonObjects) {
|
||||
try {
|
||||
const logData = JSON.parse(jsonStr);
|
||||
// Look for tool call logs
|
||||
if (
|
||||
logData.attributes &&
|
||||
logData.attributes['event.name'] === 'gemini_cli.tool_call'
|
||||
) {
|
||||
const toolName = logData.attributes.function_name;
|
||||
logs.push({
|
||||
toolRequest: {
|
||||
name: toolName,
|
||||
args: logData.attributes.function_args,
|
||||
success: logData.attributes.success,
|
||||
duration_ms: logData.attributes.duration_ms,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip objects that aren't valid JSON
|
||||
if (env.VERBOSE === 'true') {
|
||||
console.error('Failed to parse telemetry object:', e);
|
||||
}
|
||||
for (const logData of parsedLogs) {
|
||||
// Look for tool call logs
|
||||
if (
|
||||
logData.attributes &&
|
||||
logData.attributes['event.name'] === 'gemini_cli.tool_call'
|
||||
) {
|
||||
const toolName = logData.attributes.function_name;
|
||||
logs.push({
|
||||
toolRequest: {
|
||||
name: toolName,
|
||||
args: logData.attributes.function_args,
|
||||
success: logData.attributes.success,
|
||||
duration_ms: logData.attributes.duration_ms,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,38 +768,79 @@ export class TestRig {
|
||||
}
|
||||
|
||||
readLastApiRequest(): Record<string, unknown> | null {
|
||||
// Telemetry is always written to the test directory
|
||||
const logFilePath = join(this.testDir!, 'telemetry.log');
|
||||
const logs = this._readAndParseTelemetryLog();
|
||||
const apiRequests = logs.filter(
|
||||
(logData) =>
|
||||
logData.attributes &&
|
||||
logData.attributes['event.name'] === 'gemini_cli.api_request',
|
||||
);
|
||||
return apiRequests.pop() || null;
|
||||
}
|
||||
|
||||
if (!logFilePath || !fs.existsSync(logFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
const jsonObjects = content
|
||||
.split(/}\n{/)
|
||||
.map((obj, index, array) => {
|
||||
if (index > 0) obj = '{' + obj;
|
||||
if (index < array.length - 1) obj = obj + '}';
|
||||
return obj.trim();
|
||||
})
|
||||
.filter((obj) => obj);
|
||||
|
||||
let lastApiRequest = null;
|
||||
|
||||
for (const jsonStr of jsonObjects) {
|
||||
try {
|
||||
const logData = JSON.parse(jsonStr);
|
||||
if (
|
||||
logData.attributes &&
|
||||
logData.attributes['event.name'] === 'gemini_cli.api_request'
|
||||
) {
|
||||
lastApiRequest = logData;
|
||||
readMetric(metricName: string): Record<string, unknown> | null {
|
||||
const logs = this._readAndParseTelemetryLog();
|
||||
for (const logData of logs) {
|
||||
if (logData.scopeMetrics) {
|
||||
for (const scopeMetric of logData.scopeMetrics) {
|
||||
for (const metric of scopeMetric.metrics) {
|
||||
if (metric.descriptor.name === `gemini_cli.${metricName}`) {
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return lastApiRequest;
|
||||
return null;
|
||||
}
|
||||
|
||||
async waitForText(text: string, timeout?: number): Promise<boolean> {
|
||||
if (!timeout) {
|
||||
timeout = this.getDefaultTimeout();
|
||||
}
|
||||
return this.poll(
|
||||
() =>
|
||||
stripAnsi(this._interactiveOutput)
|
||||
.toLowerCase()
|
||||
.includes(text.toLowerCase()),
|
||||
timeout,
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
runInteractive(...args: string[]): {
|
||||
ptyProcess: pty.IPty;
|
||||
promise: Promise<{ exitCode: number; signal?: number; output: string }>;
|
||||
} {
|
||||
const { command, initialArgs } = this._getCommandAndArgs(['--yolo']);
|
||||
const commandArgs = [...initialArgs, ...args];
|
||||
|
||||
this._interactiveOutput = ''; // Reset output for the new run
|
||||
|
||||
const ptyProcess = pty.spawn(command, commandArgs, {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
cwd: this.testDir!,
|
||||
env: process.env as { [key: string]: string },
|
||||
});
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
this._interactiveOutput += data;
|
||||
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
||||
process.stdout.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
const promise = new Promise<{
|
||||
exitCode: number;
|
||||
signal?: number;
|
||||
output: string;
|
||||
}>((resolve) => {
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
resolve({ exitCode, signal, output: this._interactiveOutput });
|
||||
});
|
||||
});
|
||||
|
||||
return { ptyProcess, promise };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ export default defineConfig({
|
||||
reporters: ['default'],
|
||||
include: ['**/*.test.ts'],
|
||||
retry: 2,
|
||||
fileParallelism: false,
|
||||
fileParallelism: true,
|
||||
poolOptions: {
|
||||
threads: {
|
||||
minThreads: 8,
|
||||
maxThreads: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+1380
-362
File diff suppressed because it is too large
Load Diff
+32
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.2",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,10 +14,10 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.6.0"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node scripts/start.js",
|
||||
"start": "cross-env node scripts/start.js",
|
||||
"start:a2a-server": "CODER_AGENT_PORT=41242 npm run start --workspace @google/gemini-cli-a2a-server",
|
||||
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
|
||||
"auth:npm": "npx google-artifactregistry-auth",
|
||||
@@ -29,10 +29,10 @@
|
||||
"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",
|
||||
"build:sandbox": "node scripts/build_sandbox.js --skip-npm-install-build",
|
||||
"build:sandbox": "node scripts/build_sandbox.js",
|
||||
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
|
||||
"test": "npm run test --workspaces --if-present --parallel",
|
||||
"test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts",
|
||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
|
||||
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
|
||||
@@ -46,12 +46,20 @@
|
||||
"format": "prettier --experimental-cli --write .",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
|
||||
"prepare": "npm run bundle",
|
||||
"prepare": "husky && npm run bundle",
|
||||
"prepare:package": "node scripts/prepare-package.js",
|
||||
"release:version": "node scripts/version.js",
|
||||
"telemetry": "node scripts/telemetry.js",
|
||||
"check:lockfile": "node scripts/check-lockfile.js",
|
||||
"clean": "node scripts/clean.js"
|
||||
"clean": "node scripts/clean.js",
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ansi-regex": "5.0.1",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
@@ -79,19 +87,26 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"glob": "^10.4.5",
|
||||
"globals": "^16.0.0",
|
||||
"google-artifactregistry-auth": "^3.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"json": "^11.0.0",
|
||||
"memfs": "^4.17.2",
|
||||
"lint-staged": "^16.1.6",
|
||||
"memfs": "^4.42.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"mock-fs": "^5.5.0",
|
||||
"msw": "^2.10.4",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.5.3",
|
||||
"react-devtools-core": "^4.28.5",
|
||||
"semver": "^7.7.2",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -103,10 +118,13 @@
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"node-pty": "^1.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
}
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix --max-warnings 0"
|
||||
],
|
||||
"*.{json,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ message DevelopmentToolEvent {
|
||||
// The tier of the user (optional).
|
||||
string user_tier = 3;
|
||||
|
||||
// An unexpected error occured in the agent execution (optional).
|
||||
// An unexpected error occurred in the agent execution (optional).
|
||||
string error = 4;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
|
||||
@@ -15,18 +15,17 @@ import {
|
||||
isNodeError,
|
||||
parseAndFormatApiError,
|
||||
safeLiteralReplace,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
ToolConfirmationPayload,
|
||||
CompletedToolCall,
|
||||
ToolCall,
|
||||
ToolCallRequestInfo,
|
||||
ServerGeminiErrorEvent,
|
||||
ServerGeminiStreamEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
UserTierId,
|
||||
AnsiOutput,
|
||||
type AnyDeclarativeTool,
|
||||
type ToolCall,
|
||||
type ToolConfirmationPayload,
|
||||
type CompletedToolCall,
|
||||
type ToolCallRequestInfo,
|
||||
type ServerGeminiErrorEvent,
|
||||
type ServerGeminiStreamEvent,
|
||||
type ToolCallConfirmationDetails,
|
||||
type Config,
|
||||
type UserTierId,
|
||||
type AnsiOutput,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { RequestContext } from '@a2a-js/sdk/server';
|
||||
import { type ExecutionEventBus } from '@a2a-js/sdk/server';
|
||||
@@ -54,6 +53,8 @@ import type {
|
||||
} from '../types.js';
|
||||
import type { PartUnion, Part as genAiPart } from '@google/genai';
|
||||
|
||||
type UnionKeys<T> = T extends T ? keyof T : never;
|
||||
|
||||
export class Task {
|
||||
id: string;
|
||||
contextId: string;
|
||||
@@ -436,6 +437,19 @@ export class Task {
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
private _pickFields<
|
||||
T extends ToolCall | AnyDeclarativeTool,
|
||||
K extends UnionKeys<T>,
|
||||
>(from: T, ...fields: K[]): Partial<T> {
|
||||
const ret = {} as Pick<T, K>;
|
||||
for (const field of fields) {
|
||||
if (field in from) {
|
||||
ret[field] = from[field];
|
||||
}
|
||||
}
|
||||
return ret as Partial<T>;
|
||||
}
|
||||
|
||||
private toolStatusMessage(
|
||||
tc: ToolCall,
|
||||
taskId: string,
|
||||
@@ -444,33 +458,33 @@ export class Task {
|
||||
const messageParts: Part[] = [];
|
||||
|
||||
// Create a serializable version of the ToolCall (pick necesssary
|
||||
// properties/avoic methods causing circular reference errors)
|
||||
const serializableToolCall: { [key: string]: unknown } = {
|
||||
request: tc.request,
|
||||
status: tc.status,
|
||||
};
|
||||
|
||||
// For WaitingToolCall type
|
||||
if ('confirmationDetails' in tc) {
|
||||
serializableToolCall['confirmationDetails'] = tc.confirmationDetails;
|
||||
}
|
||||
// properties/avoid methods causing circular reference errors)
|
||||
const serializableToolCall: Partial<ToolCall> = this._pickFields(
|
||||
tc,
|
||||
'request',
|
||||
'status',
|
||||
'confirmationDetails',
|
||||
'liveOutput',
|
||||
'response',
|
||||
);
|
||||
|
||||
if (tc.tool) {
|
||||
serializableToolCall['tool'] = {
|
||||
name: tc.tool.name,
|
||||
displayName: tc.tool.displayName,
|
||||
description: tc.tool.description,
|
||||
kind: tc.tool.kind,
|
||||
isOutputMarkdown: tc.tool.isOutputMarkdown,
|
||||
canUpdateOutput: tc.tool.canUpdateOutput,
|
||||
schema: tc.tool.schema,
|
||||
parameterSchema: tc.tool.parameterSchema,
|
||||
};
|
||||
serializableToolCall.tool = this._pickFields(
|
||||
tc.tool,
|
||||
'name',
|
||||
'displayName',
|
||||
'description',
|
||||
'kind',
|
||||
'isOutputMarkdown',
|
||||
'canUpdateOutput',
|
||||
'schema',
|
||||
'parameterSchema',
|
||||
) as AnyDeclarativeTool;
|
||||
}
|
||||
|
||||
messageParts.push({
|
||||
kind: 'data',
|
||||
data: serializableToolCall as ToolCall,
|
||||
data: serializableToolCall,
|
||||
} as Part);
|
||||
|
||||
return {
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('GCSTaskStore', () => {
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
it('should save metadata and workspace', async () => {
|
||||
it.skip('should save metadata and workspace', async () => {
|
||||
const store = new GCSTaskStore(bucketName);
|
||||
await store.save(mockTask);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2021"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"composite": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
|
||||
@@ -4,15 +4,25 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
reporters: [['default'], ['junit', { outputFile: 'junit.xml' }]],
|
||||
passWithNoTests: true,
|
||||
include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
reporters: ['default', 'junit'],
|
||||
silent: true,
|
||||
outputFile: {
|
||||
junit: 'junit.xml',
|
||||
},
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: 'v8',
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*'],
|
||||
reporter: [
|
||||
['text', { file: 'full-text-summary.txt' }],
|
||||
'html',
|
||||
@@ -22,5 +32,16 @@ export default defineConfig({
|
||||
['json-summary', { outputFile: 'coverage-summary.json' }],
|
||||
],
|
||||
},
|
||||
poolOptions: {
|
||||
threads: {
|
||||
minThreads: 8,
|
||||
maxThreads: 16,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
deps: {
|
||||
inline: [/@google\/gemini-cli-core/],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.2",
|
||||
"description": "Gemini CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -18,14 +18,14 @@
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run --coverage",
|
||||
"test:ci": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.6.0"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -33,12 +33,13 @@
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.4.1",
|
||||
"glob": "^10.4.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "^6.2.3",
|
||||
"ink-gradient": "^3.0.0",
|
||||
@@ -47,12 +48,14 @@
|
||||
"open": "^10.1.2",
|
||||
"react": "^19.1.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
"simple-git": "^3.28.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.1",
|
||||
"undici": "^7.10.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"yargs": "^17.7.2",
|
||||
@@ -62,6 +65,7 @@
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
@@ -70,7 +74,9 @@
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"pretty-format": "^30.0.2",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"name": "context-example",
|
||||
"version": "1.0.0",
|
||||
"contextFileName": "GEMINI.md"
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "mcp-server",
|
||||
"name": "mcp-server-example",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}example.ts"]
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "mcp-server-example",
|
||||
"version": "1.0.0",
|
||||
"description": "Example MCP Server for Gemini CLI Extension",
|
||||
"type": "module",
|
||||
"main": "example.js",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.4.5",
|
||||
"@types/node": "^20.11.25"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["example.ts"]
|
||||
}
|
||||
@@ -9,38 +9,32 @@ import { handleInstall, installCommand } from './install.js';
|
||||
import yargs from 'yargs';
|
||||
|
||||
const mockInstallExtension = vi.hoisted(() => vi.fn());
|
||||
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
|
||||
const mockStat = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../config/extension.js', () => ({
|
||||
installExtension: mockInstallExtension,
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/errors.js', () => ({
|
||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: mockStat,
|
||||
default: {
|
||||
stat: mockStat,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('extensions install command', () => {
|
||||
it('should fail if no source is provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
expect(() => validationParser.parse('install')).toThrow(
|
||||
'Either source or --path must be provided.',
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if both git source and local path are provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
expect(() =>
|
||||
validationParser.parse('install some-url --path /some/path'),
|
||||
).toThrow('Arguments source and path are mutually exclusive');
|
||||
});
|
||||
|
||||
it('should fail if both auto update and local path are provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
expect(() =>
|
||||
validationParser.parse(
|
||||
'install some-url --path /some/path --auto-update',
|
||||
),
|
||||
).toThrow('Arguments path and auto-update are mutually exclusive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleInstall', () => {
|
||||
@@ -58,6 +52,8 @@ describe('handleInstall', () => {
|
||||
|
||||
afterEach(() => {
|
||||
mockInstallExtension.mockClear();
|
||||
mockRequestConsentNonInteractive.mockClear();
|
||||
mockStat.mockClear();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
@@ -98,13 +94,12 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('throws an error from an unknown source', async () => {
|
||||
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
||||
await handleInstall({
|
||||
source: 'test://google.com',
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'The source "test://google.com" is not a valid URL format.',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Install source not found.');
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
@@ -122,9 +117,9 @@ describe('handleInstall', () => {
|
||||
|
||||
it('should install an extension from a local path', async () => {
|
||||
mockInstallExtension.mockResolvedValue('local-extension');
|
||||
|
||||
mockStat.mockResolvedValue({});
|
||||
await handleInstall({
|
||||
path: '/some/path',
|
||||
source: '/some/path',
|
||||
});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
@@ -132,15 +127,6 @@ describe('handleInstall', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if no source or path is provided', async () => {
|
||||
await handleInstall({});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Either --source or --path must be provided.',
|
||||
);
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should throw an error if install extension fails', async () => {
|
||||
mockInstallExtension.mockRejectedValue(
|
||||
new Error('Install extension failed'),
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { installExtension } from '../../config/extension.js';
|
||||
import {
|
||||
installExtension,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extension.js';
|
||||
import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
interface InstallArgs {
|
||||
source?: string;
|
||||
path?: string;
|
||||
source: string;
|
||||
ref?: string;
|
||||
autoUpdate?: boolean;
|
||||
}
|
||||
@@ -20,35 +22,40 @@ interface InstallArgs {
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
try {
|
||||
let installMetadata: ExtensionInstallMetadata;
|
||||
if (args.source) {
|
||||
const { source } = args;
|
||||
if (
|
||||
source.startsWith('http://') ||
|
||||
source.startsWith('https://') ||
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://')
|
||||
) {
|
||||
installMetadata = {
|
||||
source,
|
||||
type: 'git',
|
||||
ref: args.ref,
|
||||
autoUpdate: args.autoUpdate,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`The source "${source}" is not a valid URL format.`);
|
||||
}
|
||||
} else if (args.path) {
|
||||
const { source } = args;
|
||||
if (
|
||||
source.startsWith('http://') ||
|
||||
source.startsWith('https://') ||
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://')
|
||||
) {
|
||||
installMetadata = {
|
||||
source: args.path,
|
||||
type: 'local',
|
||||
source,
|
||||
type: 'git',
|
||||
ref: args.ref,
|
||||
autoUpdate: args.autoUpdate,
|
||||
};
|
||||
} else {
|
||||
// This should not be reached due to the yargs check.
|
||||
throw new Error('Either --source or --path must be provided.');
|
||||
if (args.ref || args.autoUpdate) {
|
||||
throw new Error(
|
||||
'--ref and --auto-update are not applicable for local extensions.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await stat(source);
|
||||
installMetadata = {
|
||||
source,
|
||||
type: 'local',
|
||||
};
|
||||
} catch {
|
||||
throw new Error('Install source not found.');
|
||||
}
|
||||
}
|
||||
|
||||
const name = await installExtension(installMetadata, true);
|
||||
const name = await installExtension(
|
||||
installMetadata,
|
||||
requestConsentNonInteractive,
|
||||
);
|
||||
console.log(`Extension "${name}" installed successfully and enabled.`);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
@@ -57,17 +64,14 @@ export async function handleInstall(args: InstallArgs) {
|
||||
}
|
||||
|
||||
export const installCommand: CommandModule = {
|
||||
command: 'install [<source>] [--path] [--ref] [--auto-update]',
|
||||
command: 'install <source>',
|
||||
describe: 'Installs an extension from a git repository URL or a local path.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('source', {
|
||||
describe: 'The github URL of the extension to install.',
|
||||
type: 'string',
|
||||
})
|
||||
.option('path', {
|
||||
describe: 'Path to a local extension directory.',
|
||||
describe: 'The github URL or local path of the extension to install.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('ref', {
|
||||
describe: 'The git ref to install from.',
|
||||
@@ -77,19 +81,15 @@ export const installCommand: CommandModule = {
|
||||
describe: 'Enable auto-update for this extension.',
|
||||
type: 'boolean',
|
||||
})
|
||||
.conflicts('source', 'path')
|
||||
.conflicts('path', 'ref')
|
||||
.conflicts('path', 'auto-update')
|
||||
.check((argv) => {
|
||||
if (!argv.source && !argv.path) {
|
||||
throw new Error('Either source or --path must be provided.');
|
||||
if (!argv.source) {
|
||||
throw new Error('The source argument must be provided.');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleInstall({
|
||||
source: argv['source'] as string | undefined,
|
||||
path: argv['path'] as string | undefined,
|
||||
source: argv['source'] as string,
|
||||
ref: argv['ref'] as string | undefined,
|
||||
autoUpdate: argv['auto-update'] as boolean | undefined,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { installExtension } from '../../config/extension.js';
|
||||
import {
|
||||
installExtension,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extension.js';
|
||||
import type { ExtensionInstallMetadata } from '@google/gemini-cli-core';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
@@ -20,7 +23,10 @@ export async function handleLink(args: InstallArgs) {
|
||||
source: args.path,
|
||||
type: 'link',
|
||||
};
|
||||
const extensionName = await installExtension(installMetadata);
|
||||
const extensionName = await installExtension(
|
||||
installMetadata,
|
||||
requestConsentNonInteractive,
|
||||
);
|
||||
console.log(
|
||||
`Extension "${extensionName}" linked successfully and enabled.`,
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function handleList() {
|
||||
}
|
||||
console.log(
|
||||
extensions
|
||||
.map((extension, _): string => toOutputString(extension))
|
||||
.map((extension, _): string => toOutputString(extension, process.cwd()))
|
||||
.join('\n\n'),
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { newCommand } from './new.js';
|
||||
import yargs from 'yargs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
@@ -27,17 +28,24 @@ describe('extensions new command', () => {
|
||||
});
|
||||
|
||||
it('should fail if no path is provided', async () => {
|
||||
const parser = yargs([]).command(newCommand).fail(false);
|
||||
const parser = yargs([]).command(newCommand).fail(false).locale('en');
|
||||
await expect(parser.parseAsync('new')).rejects.toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 2',
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if no template is provided', async () => {
|
||||
it('should create directory when no template is provided', async () => {
|
||||
mockedFs.access.mockRejectedValue(new Error('ENOENT'));
|
||||
mockedFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
const parser = yargs([]).command(newCommand).fail(false);
|
||||
await expect(parser.parseAsync('new /some/path')).rejects.toThrow(
|
||||
'Not enough non-option arguments: got 1, need at least 2',
|
||||
);
|
||||
|
||||
await parser.parseAsync('new /some/path');
|
||||
|
||||
expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(mockedFs.cp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create directory and copy files when path does not exist', async () => {
|
||||
@@ -53,8 +61,18 @@ describe('extensions new command', () => {
|
||||
recursive: true,
|
||||
});
|
||||
expect(mockedFs.cp).toHaveBeenCalledWith(
|
||||
expect.stringContaining('context'),
|
||||
'/some/path',
|
||||
expect.stringContaining(path.normalize('context/context')),
|
||||
path.normalize('/some/path/context'),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(mockedFs.cp).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.normalize('context/custom-commands')),
|
||||
path.normalize('/some/path/custom-commands'),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(mockedFs.cp).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.normalize('context/mcp-server')),
|
||||
path.normalize('/some/path/mcp-server'),
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { access, cp, mkdir, readdir } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { access, cp, mkdir, readdir, writeFile } from 'node:fs/promises';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
interface NewArgs {
|
||||
path: string;
|
||||
template: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -29,22 +29,45 @@ async function pathExists(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDirectory(template: string, path: string) {
|
||||
async function createDirectory(path: string) {
|
||||
if (await pathExists(path)) {
|
||||
throw new Error(`Path already exists: ${path}`);
|
||||
}
|
||||
await mkdir(path, { recursive: true });
|
||||
}
|
||||
|
||||
async function copyDirectory(template: string, path: string) {
|
||||
await createDirectory(path);
|
||||
|
||||
const examplePath = join(EXAMPLES_PATH, template);
|
||||
await mkdir(path, { recursive: true });
|
||||
await cp(examplePath, path, { recursive: true });
|
||||
const entries = await readdir(examplePath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = join(examplePath, entry.name);
|
||||
const destPath = join(path, entry.name);
|
||||
await cp(srcPath, destPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNew(args: NewArgs) {
|
||||
try {
|
||||
await copyDirectory(args.template, args.path);
|
||||
console.log(
|
||||
`Successfully created new extension from template "${args.template}" at ${args.path}.`,
|
||||
);
|
||||
if (args.template) {
|
||||
await copyDirectory(args.template, args.path);
|
||||
console.log(
|
||||
`Successfully created new extension from template "${args.template}" at ${args.path}.`,
|
||||
);
|
||||
} else {
|
||||
await createDirectory(args.path);
|
||||
const extensionName = basename(args.path);
|
||||
const manifest = {
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
};
|
||||
await writeFile(
|
||||
join(args.path, 'gemini-extension.json'),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
);
|
||||
console.log(`Successfully created new extension at ${args.path}.`);
|
||||
}
|
||||
console.log(
|
||||
`You can install this using "gemini extensions link ${args.path}" to test it out.`,
|
||||
);
|
||||
@@ -62,7 +85,7 @@ async function getBoilerplateChoices() {
|
||||
}
|
||||
|
||||
export const newCommand: CommandModule = {
|
||||
command: 'new <path> <template>',
|
||||
command: 'new <path> [template]',
|
||||
describe: 'Create a new extension from a boilerplate example.',
|
||||
builder: async (yargs) => {
|
||||
const choices = await getBoilerplateChoices();
|
||||
@@ -80,7 +103,7 @@ export const newCommand: CommandModule = {
|
||||
handler: async (args) => {
|
||||
await handleNew({
|
||||
path: args['path'] as string,
|
||||
template: args['template'] as string,
|
||||
template: args['template'] as string | undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,7 +10,10 @@ import yargs from 'yargs';
|
||||
|
||||
describe('extensions uninstall command', () => {
|
||||
it('should fail if no source is provided', () => {
|
||||
const validationParser = yargs([]).command(uninstallCommand).fail(false);
|
||||
const validationParser = yargs([])
|
||||
.command(uninstallCommand)
|
||||
.fail(false)
|
||||
.locale('en');
|
||||
expect(() => validationParser.parse('uninstall')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
loadExtensions,
|
||||
annotateActiveExtensions,
|
||||
ExtensionStorage,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extension.js';
|
||||
import {
|
||||
updateAllUpdatableExtensions,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
|
||||
|
||||
interface UpdateArgs {
|
||||
name?: string;
|
||||
@@ -29,11 +32,17 @@ const updateOutput = (info: ExtensionUpdateInfo) =>
|
||||
|
||||
export async function handleUpdate(args: UpdateArgs) {
|
||||
const workingDir = process.cwd();
|
||||
const allExtensions = loadExtensions();
|
||||
const extensionEnablementManager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
// Force enable named extensions, otherwise we will only update the enabled
|
||||
// ones.
|
||||
args.name ? [args.name] : [],
|
||||
);
|
||||
const allExtensions = loadExtensions(extensionEnablementManager);
|
||||
const extensions = annotateActiveExtensions(
|
||||
allExtensions,
|
||||
allExtensions.map((e) => e.config.name),
|
||||
workingDir,
|
||||
extensionEnablementManager,
|
||||
);
|
||||
if (args.name) {
|
||||
try {
|
||||
@@ -62,6 +71,7 @@ export async function handleUpdate(args: UpdateArgs) {
|
||||
const updatedExtensionInfo = (await updateExtension(
|
||||
extension,
|
||||
workingDir,
|
||||
requestConsentNonInteractive,
|
||||
updateState,
|
||||
() => {},
|
||||
))!;
|
||||
@@ -81,10 +91,20 @@ export async function handleUpdate(args: UpdateArgs) {
|
||||
}
|
||||
if (args.all) {
|
||||
try {
|
||||
const extensionState = new Map();
|
||||
await checkForAllExtensionUpdates(extensions, (action) => {
|
||||
if (action.type === 'SET_STATE') {
|
||||
extensionState.set(action.payload.name, {
|
||||
status: action.payload.state,
|
||||
processed: true, // No need to process as we will force the update.
|
||||
});
|
||||
}
|
||||
});
|
||||
let updateInfos = await updateAllUpdatableExtensions(
|
||||
workingDir,
|
||||
requestConsentNonInteractive,
|
||||
extensions,
|
||||
await checkForAllExtensionUpdates(extensions, new Map(), (_) => {}),
|
||||
extensionState,
|
||||
() => {},
|
||||
);
|
||||
updateInfos = updateInfos.filter(
|
||||
|
||||
@@ -13,6 +13,16 @@ vi.mock('fs/promises', () => ({
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('os', () => {
|
||||
const homedir = vi.fn(() => '/home/user');
|
||||
return {
|
||||
default: {
|
||||
homedir,
|
||||
},
|
||||
homedir,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', async () => {
|
||||
const actual = await vi.importActual('../../config/settings.js');
|
||||
return {
|
||||
@@ -26,15 +36,20 @@ const mockedLoadSettings = loadSettings as vi.Mock;
|
||||
describe('mcp add command', () => {
|
||||
let parser: yargs.Argv;
|
||||
let mockSetValue: vi.Mock;
|
||||
let mockConsoleError: vi.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
const yargsInstance = yargs([]).command(addCommand);
|
||||
parser = yargsInstance;
|
||||
mockSetValue = vi.fn();
|
||||
mockConsoleError = vi.fn();
|
||||
vi.spyOn(console, 'error').mockImplementation(mockConsoleError);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
forScope: () => ({ settings: {} }),
|
||||
setValue: mockSetValue,
|
||||
workspace: { path: '/path/to/project' },
|
||||
user: { path: '/home/user' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,4 +134,218 @@ describe('mcp add command', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('when handling scope and directory', () => {
|
||||
const serverName = 'test-server';
|
||||
const command = 'echo';
|
||||
|
||||
const setupMocks = (cwd: string, workspacePath: string) => {
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(cwd);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
forScope: () => ({ settings: {} }),
|
||||
setValue: mockSetValue,
|
||||
workspace: { path: workspacePath },
|
||||
user: { path: '/home/user' },
|
||||
});
|
||||
};
|
||||
|
||||
describe('when in a project directory', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/path/to/project', '/path/to/project');
|
||||
});
|
||||
|
||||
it('should use project scope by default', async () => {
|
||||
await parser.parseAsync(`add ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use project scope when --scope=project is used', async () => {
|
||||
await parser.parseAsync(`add --scope project ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use user scope when --scope=user is used', async () => {
|
||||
await parser.parseAsync(`add --scope user ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when in a subdirectory of a project', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/path/to/project/subdir', '/path/to/project');
|
||||
});
|
||||
|
||||
it('should use project scope by default', async () => {
|
||||
await parser.parseAsync(`add ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when in the home directory', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/home/user', '/home/user');
|
||||
});
|
||||
|
||||
it('should show an error by default', async () => {
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as (code?: number) => never);
|
||||
|
||||
await expect(
|
||||
parser.parseAsync(`add ${serverName} ${command}`),
|
||||
).rejects.toThrow('process.exit called');
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
'Error: Please use --scope user to edit settings in the home directory.',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show an error when --scope=project is used explicitly', async () => {
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {
|
||||
throw new Error('process.exit called');
|
||||
}) as (code?: number) => never);
|
||||
|
||||
await expect(
|
||||
parser.parseAsync(`add --scope project ${serverName} ${command}`),
|
||||
).rejects.toThrow('process.exit called');
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
'Error: Please use --scope user to edit settings in the home directory.',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use user scope when --scope=user is used', async () => {
|
||||
await parser.parseAsync(`add --scope user ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockConsoleError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when in a subdirectory of home (not a project)', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/home/user/some/dir', '/home/user/some/dir');
|
||||
});
|
||||
|
||||
it('should use project scope by default', async () => {
|
||||
await parser.parseAsync(`add ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should write to the WORKSPACE scope, not the USER scope', async () => {
|
||||
await parser.parseAsync(`add my-new-server echo`);
|
||||
|
||||
// We expect setValue to be called once.
|
||||
expect(mockSetValue).toHaveBeenCalledTimes(1);
|
||||
|
||||
// We get the scope that setValue was called with.
|
||||
const calledScope = mockSetValue.mock.calls[0][0];
|
||||
|
||||
// We assert that the scope was Workspace, not User.
|
||||
expect(calledScope).toBe(SettingScope.Workspace);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when outside of home (not a project)', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks('/tmp/foo', '/tmp/foo');
|
||||
});
|
||||
|
||||
it('should use project scope by default', async () => {
|
||||
await parser.parseAsync(`add ${serverName} ${command}`);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updating an existing server', () => {
|
||||
const serverName = 'existing-server';
|
||||
const initialCommand = 'echo old';
|
||||
const updatedCommand = 'echo';
|
||||
const updatedArgs = ['new'];
|
||||
|
||||
beforeEach(() => {
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
forScope: () => ({
|
||||
settings: {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
command: initialCommand,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
setValue: mockSetValue,
|
||||
workspace: { path: '/path/to/project' },
|
||||
user: { path: '/home/user' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the existing server in the project scope', async () => {
|
||||
await parser.parseAsync(
|
||||
`add ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`,
|
||||
);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'mcpServers',
|
||||
expect.objectContaining({
|
||||
[serverName]: expect.objectContaining({
|
||||
command: updatedCommand,
|
||||
args: updatedArgs,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update the existing server in the user scope', async () => {
|
||||
await parser.parseAsync(
|
||||
`add --scope user ${serverName} ${updatedCommand} ${updatedArgs.join(' ')}`,
|
||||
);
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'mcpServers',
|
||||
expect.objectContaining({
|
||||
[serverName]: expect.objectContaining({
|
||||
command: updatedCommand,
|
||||
args: updatedArgs,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,9 +36,19 @@ async function addMcpServer(
|
||||
includeTools,
|
||||
excludeTools,
|
||||
} = options;
|
||||
|
||||
const settings = loadSettings(process.cwd());
|
||||
const inHome = settings.workspace.path === settings.user.path;
|
||||
|
||||
if (scope === 'project' && inHome) {
|
||||
console.error(
|
||||
'Error: Please use --scope user to edit settings in the home directory.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const settingsScope =
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
|
||||
const settings = loadSettings();
|
||||
|
||||
let newServer: Partial<MCPServerConfig> = {};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { listMcpServers } from './list.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { loadExtensions } from '../../config/extension.js';
|
||||
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
|
||||
import { createTransport } from '@google/gemini-cli-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
@@ -16,6 +16,9 @@ vi.mock('../../config/settings.js', () => ({
|
||||
}));
|
||||
vi.mock('../../config/extension.js', () => ({
|
||||
loadExtensions: vi.fn(),
|
||||
ExtensionStorage: {
|
||||
getUserExtensionsDir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
createTransport: vi.fn(),
|
||||
@@ -34,6 +37,7 @@ vi.mock('@google/gemini-cli-core', () => ({
|
||||
}));
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js');
|
||||
|
||||
const mockedExtensionStorage = ExtensionStorage as vi.Mock;
|
||||
const mockedLoadSettings = loadSettings as vi.Mock;
|
||||
const mockedLoadExtensions = loadExtensions as vi.Mock;
|
||||
const mockedCreateTransport = createTransport as vi.Mock;
|
||||
@@ -69,6 +73,9 @@ describe('mcp list command', () => {
|
||||
MockedClient.mockImplementation(() => mockClient);
|
||||
mockedCreateTransport.mockResolvedValue(mockTransport);
|
||||
mockedLoadExtensions.mockReturnValue([]);
|
||||
mockedExtensionStorage.getUserExtensionsDir.mockReturnValue(
|
||||
'/mocked/extensions/dir',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { loadSettings } from '../../config/settings.js';
|
||||
import type { MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import { MCPServerStatus, createTransport } from '@google/gemini-cli-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { loadExtensions } from '../../config/extension.js';
|
||||
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
|
||||
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
|
||||
|
||||
const COLOR_GREEN = '\u001b[32m';
|
||||
const COLOR_YELLOW = '\u001b[33m';
|
||||
@@ -21,7 +22,9 @@ async function getMcpServersFromConfig(): Promise<
|
||||
Record<string, MCPServerConfig>
|
||||
> {
|
||||
const settings = loadSettings();
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
const mcpServers = { ...(settings.merged.mcpServers || {}) };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.config.mcpServers || {}).forEach(
|
||||
|
||||
+1473
-106
File diff suppressed because it is too large
Load Diff
+177
-114
@@ -12,7 +12,6 @@ import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import type {
|
||||
TelemetryTarget,
|
||||
FileFilteringOptions,
|
||||
MCPServerConfig,
|
||||
OutputFormat,
|
||||
@@ -32,6 +31,8 @@ import {
|
||||
ShellTool,
|
||||
EditTool,
|
||||
WriteFileTool,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
@@ -43,6 +44,8 @@ import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { appEvents } from '../utils/events.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { createPolicyEngineConfig } from './policy.js';
|
||||
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
|
||||
// Simple console logger for now - replace with actual logger if available
|
||||
const logger = {
|
||||
@@ -55,6 +58,7 @@ const logger = {
|
||||
};
|
||||
|
||||
export interface CliArgs {
|
||||
query: string | undefined;
|
||||
model: string | undefined;
|
||||
sandbox: boolean | string | undefined;
|
||||
sandboxImage: string | undefined;
|
||||
@@ -81,19 +85,94 @@ export interface CliArgs {
|
||||
includeDirectories: string[] | undefined;
|
||||
screenReader: boolean | undefined;
|
||||
useSmartEdit: boolean | undefined;
|
||||
promptWords: string[] | undefined;
|
||||
useWriteTodos: boolean | undefined;
|
||||
outputFormat: string | undefined;
|
||||
}
|
||||
|
||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
const yargsInstance = yargs(hideBin(process.argv))
|
||||
const rawArgv = hideBin(process.argv);
|
||||
const yargsInstance = yargs(rawArgv)
|
||||
.locale('en')
|
||||
.scriptName('gemini')
|
||||
.usage(
|
||||
'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
|
||||
)
|
||||
.command('$0 [promptWords...]', 'Launch Gemini CLI', (yargsInstance) =>
|
||||
.option('telemetry', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
|
||||
})
|
||||
.option('telemetry-target', {
|
||||
type: 'string',
|
||||
choices: ['local', 'gcp'],
|
||||
description:
|
||||
'Set the telemetry target (local or gcp). Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-otlp-endpoint', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
|
||||
})
|
||||
.option('telemetry-otlp-protocol', {
|
||||
type: 'string',
|
||||
choices: ['grpc', 'http'],
|
||||
description:
|
||||
'Set the OTLP protocol for telemetry (grpc or http). Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-log-prompts', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-outfile', {
|
||||
type: 'string',
|
||||
description: 'Redirect all telemetry output to the specified file.',
|
||||
})
|
||||
.deprecateOption(
|
||||
'telemetry',
|
||||
'Use the "telemetry.enabled" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-target',
|
||||
'Use the "telemetry.target" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-otlp-endpoint',
|
||||
'Use the "telemetry.otlpEndpoint" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-otlp-protocol',
|
||||
'Use the "telemetry.otlpProtocol" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-log-prompts',
|
||||
'Use the "telemetry.logPrompts" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-outfile',
|
||||
'Use the "telemetry.outfile" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
type: 'boolean',
|
||||
description: 'Run in debug mode?',
|
||||
default: false,
|
||||
})
|
||||
.option('proxy', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Proxy for gemini client, like schema://user:password@host:port',
|
||||
})
|
||||
.deprecateOption(
|
||||
'proxy',
|
||||
'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>
|
||||
yargsInstance
|
||||
.positional('query', {
|
||||
description:
|
||||
'Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive.',
|
||||
})
|
||||
.option('model', {
|
||||
alias: 'm',
|
||||
type: 'string',
|
||||
@@ -119,12 +198,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
type: 'string',
|
||||
description: 'Sandbox image URI.',
|
||||
})
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
type: 'boolean',
|
||||
description: 'Run in debug mode?',
|
||||
default: false,
|
||||
})
|
||||
.option('all-files', {
|
||||
alias: ['a'],
|
||||
type: 'boolean',
|
||||
@@ -149,37 +222,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description:
|
||||
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)',
|
||||
})
|
||||
.option('telemetry', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
|
||||
})
|
||||
.option('telemetry-target', {
|
||||
type: 'string',
|
||||
choices: ['local', 'gcp'],
|
||||
description:
|
||||
'Set the telemetry target (local or gcp). Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-otlp-endpoint', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
|
||||
})
|
||||
.option('telemetry-otlp-protocol', {
|
||||
type: 'string',
|
||||
choices: ['grpc', 'http'],
|
||||
description:
|
||||
'Set the OTLP protocol for telemetry (grpc or http). Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-log-prompts', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-outfile', {
|
||||
type: 'string',
|
||||
description: 'Redirect all telemetry output to the specified file.',
|
||||
})
|
||||
.option('checkpointing', {
|
||||
alias: 'c',
|
||||
type: 'boolean',
|
||||
@@ -194,11 +236,19 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Allowed MCP server names',
|
||||
coerce: (mcpServerNames: string[]) =>
|
||||
// Handle comma-separated values
|
||||
mcpServerNames.flatMap((mcpServerName) =>
|
||||
mcpServerName.split(',').map((m) => m.trim()),
|
||||
),
|
||||
})
|
||||
.option('allowed-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Tools that are allowed to run without confirmation',
|
||||
coerce: (tools: string[]) =>
|
||||
// Handle comma-separated values
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('extensions', {
|
||||
alias: 'e',
|
||||
@@ -206,17 +256,17 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
string: true,
|
||||
description:
|
||||
'A list of extensions to use. If not provided, all extensions are used.',
|
||||
coerce: (extensions: string[]) =>
|
||||
// Handle comma-separated values
|
||||
extensions.flatMap((extension) =>
|
||||
extension.split(',').map((e) => e.trim()),
|
||||
),
|
||||
})
|
||||
.option('list-extensions', {
|
||||
alias: 'l',
|
||||
type: 'boolean',
|
||||
description: 'List all available extensions and exit.',
|
||||
})
|
||||
.option('proxy', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Proxy for gemini client, like schema://user:password@host:port',
|
||||
})
|
||||
.option('include-directories', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -229,7 +279,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
.option('screen-reader', {
|
||||
type: 'boolean',
|
||||
description: 'Enable screen reader mode for accessibility.',
|
||||
default: false,
|
||||
})
|
||||
.option('output-format', {
|
||||
alias: 'o',
|
||||
@@ -237,30 +286,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description: 'The format of the CLI output.',
|
||||
choices: ['text', 'json'],
|
||||
})
|
||||
.deprecateOption(
|
||||
'telemetry',
|
||||
'Use the "telemetry.enabled" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-target',
|
||||
'Use the "telemetry.target" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-otlp-endpoint',
|
||||
'Use the "telemetry.otlpEndpoint" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-otlp-protocol',
|
||||
'Use the "telemetry.otlpProtocol" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-log-prompts',
|
||||
'Use the "telemetry.logPrompts" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-outfile',
|
||||
'Use the "telemetry.outfile" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
@@ -269,10 +294,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
'sandbox-image',
|
||||
'Use the "tools.sandbox" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'proxy',
|
||||
'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'checkpointing',
|
||||
'Use the "general.checkpointing.enabled" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
@@ -285,22 +306,28 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
'prompt',
|
||||
'Use the positional prompt instead. This flag will be removed in a future version.',
|
||||
)
|
||||
// Ensure validation flows through .fail() for clean UX
|
||||
.fail((msg, err, yargs) => {
|
||||
console.error(msg || err?.message || 'Unknown error');
|
||||
yargs.showHelp();
|
||||
process.exit(1);
|
||||
})
|
||||
.check((argv) => {
|
||||
const promptWords = argv['promptWords'] as string[] | undefined;
|
||||
if (argv['prompt'] && promptWords && promptWords.length > 0) {
|
||||
throw new Error(
|
||||
'Cannot use both a positional prompt and the --prompt (-p) flag together',
|
||||
);
|
||||
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
|
||||
// This guard safely checks if any positional argument was provided.
|
||||
const query = argv['query'] as string | string[] | undefined;
|
||||
const hasPositionalQuery = Array.isArray(query)
|
||||
? query.length > 0
|
||||
: !!query;
|
||||
|
||||
if (argv['prompt'] && hasPositionalQuery) {
|
||||
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
|
||||
}
|
||||
if (argv['prompt'] && argv['promptInteractive']) {
|
||||
throw new Error(
|
||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||
);
|
||||
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
|
||||
}
|
||||
if (argv.yolo && argv['approvalMode']) {
|
||||
throw new Error(
|
||||
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
|
||||
);
|
||||
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
@@ -323,6 +350,8 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
yargsInstance.wrap(yargsInstance.terminalWidth());
|
||||
const result = await yargsInstance.parse();
|
||||
|
||||
// If yargs handled --help/--version it will have exited; nothing to do here.
|
||||
|
||||
// Handle case where MCP subcommands are executed - they should exit the process
|
||||
// and not return to main CLI logic
|
||||
if (
|
||||
@@ -333,6 +362,26 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Normalize query args: handle both quoted "@path file" and unquoted @path file
|
||||
const queryArg = (result as { query?: string | string[] | undefined }).query;
|
||||
const q: string | undefined = Array.isArray(queryArg)
|
||||
? queryArg.join(' ')
|
||||
: queryArg;
|
||||
|
||||
// Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands)
|
||||
if (q && !result['prompt']) {
|
||||
const hasExplicitInteractive =
|
||||
result['promptInteractive'] === '' || !!result['promptInteractive'];
|
||||
if (hasExplicitInteractive) {
|
||||
result['promptInteractive'] = q;
|
||||
} else {
|
||||
result['prompt'] = q;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep CliArgs.query as a string for downstream typing
|
||||
(result as Record<string, unknown>)['query'] = q || undefined;
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
return result as unknown as CliArgs;
|
||||
@@ -381,30 +430,36 @@ export async function loadHierarchicalGeminiMemory(
|
||||
);
|
||||
}
|
||||
|
||||
export function isDebugMode(argv: CliArgs): boolean {
|
||||
return (
|
||||
argv.debug ||
|
||||
[process.env['DEBUG'], process.env['DEBUG_MODE']].some(
|
||||
(v) => v === 'true' || v === '1',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
settings: Settings,
|
||||
extensions: Extension[],
|
||||
extensionEnablementManager: ExtensionEnablementManager,
|
||||
sessionId: string,
|
||||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<Config> {
|
||||
const debugMode =
|
||||
argv.debug ||
|
||||
[process.env['DEBUG'], process.env['DEBUG_MODE']].some(
|
||||
(v) => v === 'true' || v === '1',
|
||||
) ||
|
||||
false;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const memoryImportFormat = settings.context?.importFormat || 'tree';
|
||||
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
const trustedFolder = isWorkspaceTrusted(settings) ?? true;
|
||||
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true;
|
||||
|
||||
const allExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
argv.extensions || [],
|
||||
cwd,
|
||||
extensionEnablementManager,
|
||||
);
|
||||
|
||||
const activeExtensions = extensions.filter(
|
||||
@@ -453,8 +508,7 @@ export async function loadCliConfig(
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
const question =
|
||||
argv.promptInteractive || argv.prompt || (argv.promptWords || []).join(' ');
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
|
||||
// Determine approval mode with backward compatibility
|
||||
let approvalMode: ApprovalMode;
|
||||
@@ -489,8 +543,29 @@ export async function loadCliConfig(
|
||||
approvalMode = ApprovalMode.DEFAULT;
|
||||
}
|
||||
|
||||
let telemetrySettings;
|
||||
try {
|
||||
telemetrySettings = await resolveTelemetrySettings({
|
||||
argv,
|
||||
env: process.env as unknown as Record<string, string | undefined>,
|
||||
settings: settings.telemetry,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FatalConfigError) {
|
||||
throw new FatalConfigError(
|
||||
`Invalid telemetry configuration: ${err.message}.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const policyEngineConfig = createPolicyEngineConfig(settings, approvalMode);
|
||||
|
||||
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
|
||||
const hasQuery = !!argv.query;
|
||||
const interactive =
|
||||
!!argv.promptInteractive || (process.stdin.isTTY && question.length === 0);
|
||||
!!argv.promptInteractive ||
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive && !argv.experimentalAcp) {
|
||||
@@ -574,6 +649,7 @@ export async function loadCliConfig(
|
||||
fullContext: argv.allFiles || false,
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
||||
policyEngineConfig,
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
@@ -588,23 +664,7 @@ export async function loadCliConfig(
|
||||
...settings.ui?.accessibility,
|
||||
screenReader,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: argv.telemetry ?? settings.telemetry?.enabled,
|
||||
target: (argv.telemetryTarget ??
|
||||
settings.telemetry?.target) as TelemetryTarget,
|
||||
otlpEndpoint:
|
||||
argv.telemetryOtlpEndpoint ??
|
||||
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
|
||||
settings.telemetry?.otlpEndpoint,
|
||||
otlpProtocol: (['grpc', 'http'] as const).find(
|
||||
(p) =>
|
||||
p ===
|
||||
(argv.telemetryOtlpProtocol ?? settings.telemetry?.otlpProtocol),
|
||||
),
|
||||
logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts,
|
||||
outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile,
|
||||
useCollector: settings.telemetry?.useCollector,
|
||||
},
|
||||
telemetry: telemetrySettings,
|
||||
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
|
||||
fileFiltering: settings.context?.fileFiltering,
|
||||
checkpointing:
|
||||
@@ -641,10 +701,13 @@ export async function loadCliConfig(
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
output: {
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
},
|
||||
useModelRouter,
|
||||
enableMessageBusIntegration:
|
||||
settings.tools?.enableMessageBusIntegration ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,17 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
ExtensionStorage,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
annotateActiveExtensions,
|
||||
disableExtension,
|
||||
enableExtension,
|
||||
installExtension,
|
||||
loadExtension,
|
||||
loadExtensionConfig,
|
||||
loadExtensions,
|
||||
performWorkspaceExtensionMigration,
|
||||
requestConsentNonInteractive,
|
||||
uninstallExtension,
|
||||
type Extension,
|
||||
} from './extension.js';
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
GEMINI_DIR,
|
||||
type GeminiCLIExtension,
|
||||
ExtensionUninstallEvent,
|
||||
ExtensionDisableEvent,
|
||||
ExtensionEnableEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { execSync } from 'node:child_process';
|
||||
@@ -71,6 +75,7 @@ vi.mock('./trustedFolders.js', async (importOriginal) => {
|
||||
const mockLogExtensionEnable = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
|
||||
const mockLogExtensionDisable = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -79,9 +84,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
logExtensionEnable: mockLogExtensionEnable,
|
||||
logExtensionInstallEvent: mockLogExtensionInstallEvent,
|
||||
logExtensionUninstall: mockLogExtensionUninstall,
|
||||
logExtensionDisable: mockLogExtensionDisable,
|
||||
ExtensionEnableEvent: vi.fn(),
|
||||
ExtensionInstallEvent: vi.fn(),
|
||||
ExtensionUninstallEvent: vi.fn(),
|
||||
ExtensionDisableEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -146,7 +153,9 @@ describe('extension tests', () => {
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].path).toBe(extensionDir);
|
||||
expect(extensions[0].config.name).toBe('test-extension');
|
||||
@@ -165,7 +174,9 @@ describe('extension tests', () => {
|
||||
version: '2.0.0',
|
||||
});
|
||||
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
|
||||
expect(extensions).toHaveLength(2);
|
||||
const ext1 = extensions.find((e) => e.config.name === 'ext1');
|
||||
@@ -185,7 +196,9 @@ describe('extension tests', () => {
|
||||
contextFileName: 'my-context-file.md',
|
||||
});
|
||||
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const ext1 = extensions.find((e) => e.config.name === 'ext1');
|
||||
@@ -210,11 +223,14 @@ describe('extension tests', () => {
|
||||
SettingScope.User,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
const extensions = loadExtensions();
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
const extensions = loadExtensions(manager);
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
[],
|
||||
tempWorkspaceDir,
|
||||
manager,
|
||||
).filter((e) => e.isActive);
|
||||
expect(activeExtensions).toHaveLength(1);
|
||||
expect(activeExtensions[0].name).toBe('enabled-extension');
|
||||
@@ -234,7 +250,9 @@ describe('extension tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
expect(extensions).toHaveLength(1);
|
||||
const loadedConfig = extensions[0].config;
|
||||
const expectedCwd = path.join(
|
||||
@@ -254,13 +272,18 @@ describe('extension tests', () => {
|
||||
});
|
||||
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
|
||||
|
||||
const extensionName = await installExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'link',
|
||||
});
|
||||
const extensionName = await installExtension(
|
||||
{
|
||||
source: sourceExtDir,
|
||||
type: 'link',
|
||||
},
|
||||
async (_) => true,
|
||||
);
|
||||
|
||||
expect(extensionName).toEqual('my-linked-extension');
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
expect(extensions).toHaveLength(1);
|
||||
|
||||
const linkedExt = extensions[0];
|
||||
@@ -309,7 +332,11 @@ describe('extension tests', () => {
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
|
||||
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const extension = extensions[0];
|
||||
@@ -360,7 +387,9 @@ describe('extension tests', () => {
|
||||
JSON.stringify(extensionConfig),
|
||||
);
|
||||
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const extension = extensions[0];
|
||||
@@ -388,7 +417,9 @@ describe('extension tests', () => {
|
||||
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed
|
||||
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].config.name).toBe('good-ext');
|
||||
@@ -420,7 +451,9 @@ describe('extension tests', () => {
|
||||
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' }));
|
||||
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].config.name).toBe('good-ext');
|
||||
@@ -448,11 +481,35 @@ describe('extension tests', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
expect(extensions).toHaveLength(1);
|
||||
const loadedConfig = extensions[0].config;
|
||||
expect(loadedConfig.mcpServers?.['test-server'].trust).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw an error for invalid extension names', () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const badExtDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'bad_name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const extension = loadExtension({
|
||||
extensionDir: badExtDir,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
});
|
||||
|
||||
expect(extension).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid extension name: "bad_name"'),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('annotateActiveExtensions', () => {
|
||||
@@ -477,8 +534,8 @@ describe('extension tests', () => {
|
||||
it('should mark all extensions as active if no enabled extensions are provided', () => {
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
[],
|
||||
'/path/to/workspace',
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
expect(activeExtensions).toHaveLength(3);
|
||||
expect(activeExtensions.every((e) => e.isActive)).toBe(true);
|
||||
@@ -487,8 +544,11 @@ describe('extension tests', () => {
|
||||
it('should mark only the enabled extensions as active', () => {
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
['ext1', 'ext3'],
|
||||
'/path/to/workspace',
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
['ext1', 'ext3'],
|
||||
),
|
||||
);
|
||||
expect(activeExtensions).toHaveLength(3);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
|
||||
@@ -505,8 +565,11 @@ describe('extension tests', () => {
|
||||
it('should mark all extensions as inactive when "none" is provided', () => {
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
['none'],
|
||||
'/path/to/workspace',
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
['none'],
|
||||
),
|
||||
);
|
||||
expect(activeExtensions).toHaveLength(3);
|
||||
expect(activeExtensions.every((e) => !e.isActive)).toBe(true);
|
||||
@@ -515,8 +578,11 @@ describe('extension tests', () => {
|
||||
it('should handle case-insensitivity', () => {
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
['EXT1'],
|
||||
'/path/to/workspace',
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
['EXT1'],
|
||||
),
|
||||
);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
|
||||
true,
|
||||
@@ -527,7 +593,14 @@ describe('extension tests', () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace');
|
||||
annotateActiveExtensions(
|
||||
extensions,
|
||||
'/path/to/workspace',
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
['ext4'],
|
||||
),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -536,8 +609,10 @@ describe('extension tests', () => {
|
||||
it('should be false if autoUpdate is not set in install metadata', () => {
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
[],
|
||||
tempHomeDir,
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
activeExtensions.every(
|
||||
@@ -556,8 +631,10 @@ describe('extension tests', () => {
|
||||
}));
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensionsWithAutoUpdate,
|
||||
[],
|
||||
tempHomeDir,
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
activeExtensions.every((e) => e.installMetadata?.autoUpdate === true),
|
||||
@@ -594,8 +671,10 @@ describe('extension tests', () => {
|
||||
];
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensionsWithAutoUpdate,
|
||||
[],
|
||||
tempHomeDir,
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
activeExtensions.find((e) => e.name === 'ext1')?.installMetadata
|
||||
@@ -623,7 +702,10 @@ describe('extension tests', () => {
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
await installExtension({ source: sourceExtDir, type: 'local' });
|
||||
await installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(targetExtDir)).toBe(true);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
@@ -641,9 +723,15 @@ describe('extension tests', () => {
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await installExtension({ source: sourceExtDir, type: 'local' });
|
||||
await installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
);
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }),
|
||||
installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Extension "my-local-extension" is already installed. Please uninstall it first.',
|
||||
);
|
||||
@@ -655,7 +743,10 @@ describe('extension tests', () => {
|
||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }),
|
||||
installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
),
|
||||
).rejects.toThrow(`Configuration file not found at ${configPath}`);
|
||||
|
||||
const targetExtDir = path.join(userExtensionsDir, 'bad-extension');
|
||||
@@ -669,7 +760,10 @@ describe('extension tests', () => {
|
||||
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
|
||||
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }),
|
||||
installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new RegExp(
|
||||
`^Failed to load extension config from ${configPath.replace(
|
||||
@@ -691,7 +785,10 @@ describe('extension tests', () => {
|
||||
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
|
||||
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }),
|
||||
installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
`Invalid configuration in ${configPath}: missing "name"`,
|
||||
);
|
||||
@@ -714,7 +811,10 @@ describe('extension tests', () => {
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
await installExtension({ source: gitUrl, type: 'git' });
|
||||
await installExtension(
|
||||
{ source: gitUrl, type: 'git' },
|
||||
async (_) => true,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(targetExtDir)).toBe(true);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
@@ -736,7 +836,10 @@ describe('extension tests', () => {
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
await installExtension({ source: sourceExtDir, type: 'link' });
|
||||
await installExtension(
|
||||
{ source: sourceExtDir, type: 'link' },
|
||||
async (_) => true,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(targetExtDir)).toBe(true);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
@@ -758,7 +861,10 @@ describe('extension tests', () => {
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await installExtension({ source: sourceExtDir, type: 'local' });
|
||||
await installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
);
|
||||
|
||||
expect(mockLogExtensionInstallEvent).toHaveBeenCalled();
|
||||
});
|
||||
@@ -785,12 +891,15 @@ describe('extension tests', () => {
|
||||
mockQuestion.mockImplementation((_query, callback) => callback('y'));
|
||||
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }, true),
|
||||
installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
requestConsentNonInteractive,
|
||||
),
|
||||
).resolves.toBe('my-local-extension');
|
||||
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
`Extensions may introduce unexpected behavior.
|
||||
Ensure you have investigated the extension source and trust the author.
|
||||
`Installing extension "my-local-extension".
|
||||
**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**
|
||||
This extension will run the following MCP servers:
|
||||
* test-server (local): node server.js
|
||||
* test-server-2 (remote): https://google.com`,
|
||||
@@ -813,7 +922,10 @@ This extension will run the following MCP servers:
|
||||
mockQuestion.mockImplementation((_query, callback) => callback('y'));
|
||||
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }, true),
|
||||
installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
requestConsentNonInteractive,
|
||||
),
|
||||
).resolves.toBe('my-local-extension');
|
||||
|
||||
expect(mockQuestion).toHaveBeenCalledWith(
|
||||
@@ -838,8 +950,11 @@ This extension will run the following MCP servers:
|
||||
mockQuestion.mockImplementation((_query, callback) => callback('n'));
|
||||
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }, true),
|
||||
).rejects.toThrow('Installation cancelled by user.');
|
||||
installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
requestConsentNonInteractive,
|
||||
),
|
||||
).rejects.toThrow('Installation cancelled for "my-local-extension".');
|
||||
|
||||
expect(mockQuestion).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Do you want to continue? [Y/n]: '),
|
||||
@@ -856,11 +971,14 @@ This extension will run the following MCP servers:
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
await installExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
autoUpdate: true,
|
||||
});
|
||||
await installExtension(
|
||||
{
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
autoUpdate: true,
|
||||
},
|
||||
async (_) => true,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(targetExtDir)).toBe(true);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
@@ -886,9 +1004,34 @@ This extension will run the following MCP servers:
|
||||
},
|
||||
});
|
||||
|
||||
const mockRequestConsent = vi.fn();
|
||||
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }, false),
|
||||
installExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
mockRequestConsent,
|
||||
process.cwd(),
|
||||
// Provide its own existing config as the previous config.
|
||||
await loadExtensionConfig({
|
||||
extensionDir: sourceExtDir,
|
||||
workspaceDir: process.cwd(),
|
||||
}),
|
||||
),
|
||||
).resolves.toBe('my-local-extension');
|
||||
|
||||
expect(mockRequestConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error for invalid extension names', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'bad_name',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await expect(
|
||||
installExtension({ source: sourceExtDir, type: 'local' }),
|
||||
).rejects.toThrow('Invalid extension name: "bad_name"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -920,7 +1063,13 @@ This extension will run the following MCP servers:
|
||||
await uninstallExtension('my-local-extension');
|
||||
|
||||
expect(fs.existsSync(sourceExtDir)).toBe(false);
|
||||
expect(loadExtensions()).toHaveLength(1);
|
||||
expect(
|
||||
loadExtensions(
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(fs.existsSync(otherExtDir)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1024,12 +1173,15 @@ This extension will run the following MCP servers:
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await performWorkspaceExtensionMigration([
|
||||
loadExtension({
|
||||
extensionDir: ext1Path,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
]);
|
||||
await performWorkspaceExtensionMigration(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: ext1Path,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
async (_) => true,
|
||||
);
|
||||
|
||||
const userExtensionsDir = path.join(
|
||||
tempHomeDir,
|
||||
@@ -1047,13 +1199,20 @@ This extension will run the following MCP servers:
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await performWorkspaceExtensionMigration([
|
||||
loadExtension({
|
||||
extensionDir: ext1Path,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
]);
|
||||
const extensions = loadExtensions();
|
||||
await performWorkspaceExtensionMigration(
|
||||
[
|
||||
loadExtension({
|
||||
extensionDir: ext1Path,
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
async (_) => true,
|
||||
);
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(extensions).toEqual([]);
|
||||
});
|
||||
@@ -1080,8 +1239,10 @@ This extension will run the following MCP servers:
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
];
|
||||
const failed =
|
||||
await performWorkspaceExtensionMigration(extensionsToMigrate);
|
||||
const failed = await performWorkspaceExtensionMigration(
|
||||
extensionsToMigrate,
|
||||
async (_) => true,
|
||||
);
|
||||
|
||||
expect(failed).toEqual([]);
|
||||
|
||||
@@ -1091,7 +1252,9 @@ This extension will run the following MCP servers:
|
||||
'extensions',
|
||||
);
|
||||
const userExt1Path = path.join(userExtensionsDir, 'ext1');
|
||||
const extensions = loadExtensions();
|
||||
const extensions = loadExtensions(
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
);
|
||||
|
||||
expect(extensions).toHaveLength(2);
|
||||
const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME);
|
||||
@@ -1122,13 +1285,22 @@ This extension will run the following MCP servers:
|
||||
},
|
||||
];
|
||||
|
||||
const failed = await performWorkspaceExtensionMigration(extensions);
|
||||
const failed = await performWorkspaceExtensionMigration(
|
||||
extensions,
|
||||
async (_) => true,
|
||||
);
|
||||
expect(failed).toEqual(['ext2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableExtension', () => {
|
||||
it('should disable an extension at the user scope', () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
disableExtension('my-extension', SettingScope.User);
|
||||
expect(
|
||||
isEnabled({
|
||||
@@ -1140,6 +1312,12 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
it('should disable an extension at the workspace scope', () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
disableExtension(
|
||||
'my-extension',
|
||||
SettingScope.Workspace,
|
||||
@@ -1162,6 +1340,12 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
it('should handle disabling the same extension twice', () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
disableExtension('my-extension', SettingScope.User);
|
||||
disableExtension('my-extension', SettingScope.User);
|
||||
expect(
|
||||
@@ -1178,6 +1362,22 @@ This extension will run the following MCP servers:
|
||||
disableExtension('my-extension', SettingScope.System),
|
||||
).toThrow('System and SystemDefaults scopes are not supported.');
|
||||
});
|
||||
|
||||
it('should log a disable event', () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
disableExtension('ext1', SettingScope.Workspace);
|
||||
|
||||
expect(mockLogExtensionDisable).toHaveBeenCalled();
|
||||
expect(ExtensionDisableEvent).toHaveBeenCalledWith(
|
||||
'ext1',
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableExtension', () => {
|
||||
@@ -1186,11 +1386,14 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
const getActiveExtensions = (): GeminiCLIExtension[] => {
|
||||
const extensions = loadExtensions();
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
const extensions = loadExtensions(manager);
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
[],
|
||||
tempWorkspaceDir,
|
||||
manager,
|
||||
);
|
||||
return activeExtensions.filter((e) => e.isActive);
|
||||
};
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
Config,
|
||||
ExtensionInstallEvent,
|
||||
ExtensionUninstallEvent,
|
||||
ExtensionDisableEvent,
|
||||
ExtensionEnableEvent,
|
||||
logExtensionEnable,
|
||||
logExtensionInstallEvent,
|
||||
logExtensionUninstall,
|
||||
logExtensionDisable,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
@@ -35,6 +37,8 @@ import {
|
||||
} from './extensions/github.js';
|
||||
import type { LoadExtensionContext } from './extensions/variableSchema.js';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import chalk from 'chalk';
|
||||
import type { ConfirmationRequest } from '../ui/types.js';
|
||||
|
||||
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||
|
||||
@@ -109,6 +113,7 @@ export async function copyExtension(
|
||||
|
||||
export async function performWorkspaceExtensionMigration(
|
||||
extensions: Extension[],
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
): Promise<string[]> {
|
||||
const failedInstallNames: string[] = [];
|
||||
|
||||
@@ -118,7 +123,7 @@ export async function performWorkspaceExtensionMigration(
|
||||
source: extension.path,
|
||||
type: 'local',
|
||||
};
|
||||
await installExtension(installMetadata);
|
||||
await installExtension(installMetadata, requestConsent);
|
||||
} catch (_) {
|
||||
failedInstallNames.push(extension.config.name);
|
||||
}
|
||||
@@ -141,6 +146,7 @@ function getTelemetryConfig(cwd: string) {
|
||||
}
|
||||
|
||||
export function loadExtensions(
|
||||
extensionEnablementManager: ExtensionEnablementManager,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Extension[] {
|
||||
const settings = loadSettings(workspaceDir).merged;
|
||||
@@ -155,14 +161,11 @@ export function loadExtensions(
|
||||
}
|
||||
|
||||
const uniqueExtensions = new Map<string, Extension>();
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
|
||||
for (const extension of allExtensions) {
|
||||
if (
|
||||
!uniqueExtensions.has(extension.config.name) &&
|
||||
manager.isEnabled(extension.config.name, workspaceDir)
|
||||
extensionEnablementManager.isEnabled(extension.config.name, workspaceDir)
|
||||
) {
|
||||
uniqueExtensions.set(extension.config.name, extension);
|
||||
}
|
||||
@@ -255,6 +258,32 @@ export function loadExtension(context: LoadExtensionContext): Extension | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadExtensionByName(
|
||||
name: string,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Extension | null {
|
||||
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
if (!fs.existsSync(userExtensionsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const subdir of fs.readdirSync(userExtensionsDir)) {
|
||||
const extensionDir = path.join(userExtensionsDir, subdir);
|
||||
if (!fs.statSync(extensionDir).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const extension = loadExtension({ extensionDir, workspaceDir });
|
||||
if (
|
||||
extension &&
|
||||
extension.config.name.toLowerCase() === name.toLowerCase()
|
||||
) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { trust, ...rest } = original;
|
||||
@@ -292,72 +321,68 @@ function getContextFileNames(config: ExtensionConfig): string[] {
|
||||
*/
|
||||
export function annotateActiveExtensions(
|
||||
extensions: Extension[],
|
||||
enabledExtensionNames: string[],
|
||||
workspaceDir: string,
|
||||
manager: ExtensionEnablementManager,
|
||||
): GeminiCLIExtension[] {
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
const annotatedExtensions: GeminiCLIExtension[] = [];
|
||||
if (enabledExtensionNames.length === 0) {
|
||||
return extensions.map((extension) => ({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: manager.isEnabled(extension.config.name, workspaceDir),
|
||||
path: extension.path,
|
||||
installMetadata: extension.installMetadata,
|
||||
}));
|
||||
}
|
||||
|
||||
const lowerCaseEnabledExtensions = new Set(
|
||||
enabledExtensionNames.map((e) => e.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
if (
|
||||
lowerCaseEnabledExtensions.size === 1 &&
|
||||
lowerCaseEnabledExtensions.has('none')
|
||||
) {
|
||||
return extensions.map((extension) => ({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: false,
|
||||
path: extension.path,
|
||||
installMetadata: extension.installMetadata,
|
||||
}));
|
||||
}
|
||||
|
||||
const notFoundNames = new Set(lowerCaseEnabledExtensions);
|
||||
|
||||
for (const extension of extensions) {
|
||||
const lowerCaseName = extension.config.name.toLowerCase();
|
||||
const isActive = lowerCaseEnabledExtensions.has(lowerCaseName);
|
||||
|
||||
if (isActive) {
|
||||
notFoundNames.delete(lowerCaseName);
|
||||
}
|
||||
|
||||
annotatedExtensions.push({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive,
|
||||
path: extension.path,
|
||||
installMetadata: extension.installMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
for (const requestedName of notFoundNames) {
|
||||
console.error(`Extension not found: ${requestedName}`);
|
||||
}
|
||||
|
||||
return annotatedExtensions;
|
||||
manager.validateExtensionOverrides(extensions);
|
||||
return extensions.map((extension) => ({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: manager.isEnabled(extension.config.name, workspaceDir),
|
||||
path: extension.path,
|
||||
installMetadata: extension.installMetadata,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users a prompt and awaits for a y/n response
|
||||
* Requests consent from the user to perform an action, by reading a Y/n
|
||||
* character from stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentNonInteractive(
|
||||
consentDescription: string,
|
||||
): Promise<boolean> {
|
||||
console.info(consentDescription);
|
||||
const result = await promptForConsentNonInteractive(
|
||||
'Do you want to continue? [Y/n]: ',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, in interactive mode.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will not work.
|
||||
*
|
||||
* @param consentDescription The description of the thing they will be consenting to.
|
||||
* @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentInteractive(
|
||||
consentDescription: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return await promptForConsentInteractive(
|
||||
consentDescription + '\n\nDo you want to continue?',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users a prompt and awaits for a y/n response on stdin.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A yes/no prompt to ask the user
|
||||
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
|
||||
*/
|
||||
async function promptForContinuation(prompt: string): Promise<boolean> {
|
||||
async function promptForConsentNonInteractive(
|
||||
prompt: string,
|
||||
): Promise<boolean> {
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
@@ -372,10 +397,34 @@ async function promptForContinuation(prompt: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks users an interactive yes/no prompt.
|
||||
*
|
||||
* This should not be called from non-interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param prompt A markdown prompt to ask the user
|
||||
* @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
|
||||
* @returns Whether or not the user answers yes.
|
||||
*/
|
||||
async function promptForConsentInteractive(
|
||||
prompt: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
addExtensionUpdateConfirmationRequest({
|
||||
prompt,
|
||||
onConfirm: (resolvedConfirmed) => {
|
||||
resolve(resolvedConfirmed);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function installExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
askConsent: boolean = false,
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
cwd: string = process.cwd(),
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
): Promise<string> {
|
||||
const telemetryConfig = getTelemetryConfig(cwd);
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
@@ -447,9 +496,11 @@ export async function installExtension(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
);
|
||||
}
|
||||
if (askConsent) {
|
||||
await requestConsent(newExtensionConfig);
|
||||
}
|
||||
await maybeRequestConsentOrFail(
|
||||
newExtensionConfig,
|
||||
requestConsent,
|
||||
previousExtensionConfig,
|
||||
);
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
if (
|
||||
@@ -510,12 +561,16 @@ export async function installExtension(
|
||||
}
|
||||
}
|
||||
|
||||
async function requestConsent(extensionConfig: ExtensionConfig) {
|
||||
/**
|
||||
* Builds a consent string for installing an extension based on it's
|
||||
* extensionConfig.
|
||||
*/
|
||||
function extensionConsentString(extensionConfig: ExtensionConfig): string {
|
||||
const output: string[] = [];
|
||||
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
|
||||
output.push('Extensions may introduce unexpected behavior.');
|
||||
output.push(`Installing extension "${extensionConfig.name}".`);
|
||||
output.push(
|
||||
'Ensure you have investigated the extension source and trust the author.',
|
||||
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**',
|
||||
);
|
||||
|
||||
if (mcpServerEntries.length) {
|
||||
@@ -538,12 +593,42 @@ async function requestConsent(extensionConfig: ExtensionConfig) {
|
||||
`This extension will exclude the following core tools: ${extensionConfig.excludeTools}`,
|
||||
);
|
||||
}
|
||||
console.info(output.join('\n'));
|
||||
const shouldContinue = await promptForContinuation(
|
||||
'Do you want to continue? [Y/n]: ',
|
||||
);
|
||||
if (!shouldContinue) {
|
||||
throw new Error('Installation cancelled by user.');
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to install an extension (extensionConfig), if
|
||||
* there is any difference between the consent string for `extensionConfig` and
|
||||
* `previousExtensionConfig`.
|
||||
*
|
||||
* Always requests consent if previousExtensionConfig is null.
|
||||
*
|
||||
* Throws if the user does not consent.
|
||||
*/
|
||||
async function maybeRequestConsentOrFail(
|
||||
extensionConfig: ExtensionConfig,
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
) {
|
||||
const extensionConsent = extensionConsentString(extensionConfig);
|
||||
if (previousExtensionConfig) {
|
||||
const previousExtensionConsent = extensionConsentString(
|
||||
previousExtensionConfig,
|
||||
);
|
||||
if (previousExtensionConsent === extensionConsent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await requestConsent(extensionConsent))) {
|
||||
throw new Error(`Installation cancelled for "${extensionConfig.name}".`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateName(name: string) {
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,6 +653,7 @@ export function loadExtensionConfig(
|
||||
`Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`,
|
||||
);
|
||||
}
|
||||
validateName(config.name);
|
||||
return config;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
@@ -596,6 +682,7 @@ export async function uninstallExtension(
|
||||
}
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
[extensionName],
|
||||
);
|
||||
manager.remove(extensionName);
|
||||
const storage = new ExtensionStorage(extensionName);
|
||||
@@ -610,8 +697,21 @@ export async function uninstallExtension(
|
||||
);
|
||||
}
|
||||
|
||||
export function toOutputString(extension: Extension): string {
|
||||
let output = `${extension.config.name} (${extension.config.version})`;
|
||||
export function toOutputString(
|
||||
extension: Extension,
|
||||
workspaceDir: string,
|
||||
): string {
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
const userEnabled = manager.isEnabled(extension.config.name, os.homedir());
|
||||
const workspaceEnabled = manager.isEnabled(
|
||||
extension.config.name,
|
||||
workspaceDir,
|
||||
);
|
||||
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${status} ${extension.config.name} (${extension.config.version})`;
|
||||
output += `\n Path: ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||
@@ -622,6 +722,8 @@ export function toOutputString(extension: Extension): string {
|
||||
output += `\n Release tag: ${extension.installMetadata.releaseTag}`;
|
||||
}
|
||||
}
|
||||
output += `\n Enabled (User): ${userEnabled}`;
|
||||
output += `\n Enabled (Workspace): ${workspaceEnabled}`;
|
||||
if (extension.contextFiles.length > 0) {
|
||||
output += `\n Context files:`;
|
||||
extension.contextFiles.forEach((contextFile) => {
|
||||
@@ -648,15 +750,22 @@ export function disableExtension(
|
||||
scope: SettingScope,
|
||||
cwd: string = process.cwd(),
|
||||
) {
|
||||
const config = getTelemetryConfig(cwd);
|
||||
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
|
||||
throw new Error('System and SystemDefaults scopes are not supported.');
|
||||
}
|
||||
const extension = loadExtensionByName(name, cwd);
|
||||
if (!extension) {
|
||||
throw new Error(`Extension with name ${name} does not exist.`);
|
||||
}
|
||||
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
[name],
|
||||
);
|
||||
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
|
||||
manager.disable(name, true, scopePath);
|
||||
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
|
||||
}
|
||||
|
||||
export function enableExtension(
|
||||
@@ -667,6 +776,10 @@ export function enableExtension(
|
||||
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
|
||||
throw new Error('System and SystemDefaults scopes are not supported.');
|
||||
}
|
||||
const extension = loadExtensionByName(name, cwd);
|
||||
if (!extension) {
|
||||
throw new Error(`Extension with name ${name} does not exist.`);
|
||||
}
|
||||
const manager = new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ExtensionEnablementManager, Override } from './extensionEnablement.js';
|
||||
import type { Extension } from '../extension.js';
|
||||
|
||||
// Helper to create a temporary directory for testing
|
||||
function createTestDir() {
|
||||
@@ -225,6 +226,99 @@ describe('ExtensionEnablementManager', () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
describe('extension overrides (-e <name>)', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ExtensionEnablementManager(configDir, ['ext-test']);
|
||||
});
|
||||
|
||||
it('can enable extensions, case-insensitive', () => {
|
||||
manager.disable('ext-test', true, '/');
|
||||
expect(manager.isEnabled('ext-test', '/')).toBe(true);
|
||||
expect(manager.isEnabled('Ext-Test', '/')).toBe(true);
|
||||
// Double check that it would have been disabled otherwise
|
||||
expect(
|
||||
new ExtensionEnablementManager(configDir).isEnabled('ext-test', '/'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('disable all other extensions', () => {
|
||||
manager = new ExtensionEnablementManager(configDir, ['ext-test']);
|
||||
manager.enable('ext-test-2', true, '/');
|
||||
expect(manager.isEnabled('ext-test-2', '/')).toBe(false);
|
||||
// Double check that it would have been enabled otherwise
|
||||
expect(
|
||||
new ExtensionEnablementManager(configDir).isEnabled('ext-test-2', '/'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('none disables all extensions', () => {
|
||||
manager = new ExtensionEnablementManager(configDir, ['none']);
|
||||
manager.enable('ext-test', true, '/');
|
||||
expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(false);
|
||||
// Double check that it would have been enabled otherwise
|
||||
expect(
|
||||
new ExtensionEnablementManager(configDir).isEnabled('ext-test', '/'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExtensionOverrides', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not log an error if enabledExtensionNamesOverride is empty', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, []);
|
||||
manager.validateExtensionOverrides([]);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log an error if all enabledExtensionNamesOverride are valid', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, [
|
||||
'ext-one',
|
||||
'ext-two',
|
||||
]);
|
||||
const extensions = [
|
||||
{ config: { name: 'ext-one' } },
|
||||
{ config: { name: 'ext-two' } },
|
||||
] as Extension[];
|
||||
manager.validateExtensionOverrides(extensions);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log an error for each invalid extension name in enabledExtensionNamesOverride', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, [
|
||||
'ext-one',
|
||||
'ext-invalid',
|
||||
'ext-another-invalid',
|
||||
]);
|
||||
const extensions = [
|
||||
{ config: { name: 'ext-one' } },
|
||||
{ config: { name: 'ext-two' } },
|
||||
] as Extension[];
|
||||
manager.validateExtensionOverrides(extensions);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Extension not found: ext-invalid',
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Extension not found: ext-another-invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not log an error if "none" is in enabledExtensionNamesOverride', () => {
|
||||
const manager = new ExtensionEnablementManager(configDir, ['none']);
|
||||
manager.validateExtensionOverrides([]);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Override', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { type Extension } from '../extension.js';
|
||||
|
||||
export interface ExtensionEnablementConfig {
|
||||
overrides: string[];
|
||||
@@ -104,24 +105,61 @@ function globToRegex(glob: string): RegExp {
|
||||
return new RegExp(`^${regexString}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an extension is enabled based on the configuration and current path.
|
||||
* The last matching rule in the overrides list wins.
|
||||
*
|
||||
* @param config The enablement configuration for a single extension.
|
||||
* @param currentPath The absolute path of the current working directory.
|
||||
* @returns True if the extension is enabled, false otherwise.
|
||||
*/
|
||||
export class ExtensionEnablementManager {
|
||||
private configFilePath: string;
|
||||
private configDir: string;
|
||||
// If non-empty, this overrides all other extension configuration and enables
|
||||
// only the ones in this list.
|
||||
private enabledExtensionNamesOverride: string[];
|
||||
|
||||
constructor(configDir: string) {
|
||||
constructor(configDir: string, enabledExtensionNames?: string[]) {
|
||||
this.configDir = configDir;
|
||||
this.configFilePath = path.join(configDir, 'extension-enablement.json');
|
||||
this.enabledExtensionNamesOverride =
|
||||
enabledExtensionNames?.map((name) => name.toLowerCase()) ?? [];
|
||||
}
|
||||
|
||||
validateExtensionOverrides(extensions: Extension[]) {
|
||||
for (const name of this.enabledExtensionNamesOverride) {
|
||||
if (name === 'none') continue;
|
||||
if (
|
||||
!extensions.some(
|
||||
(ext) => ext.config.name.toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
console.error(`Extension not found: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an extension is enabled based on its name and the current
|
||||
* path. The last matching rule in the overrides list wins.
|
||||
*
|
||||
* @param extensionName The name of the extension.
|
||||
* @param currentPath The absolute path of the current working directory.
|
||||
* @returns True if the extension is enabled, false otherwise.
|
||||
*/
|
||||
isEnabled(extensionName: string, currentPath: string): boolean {
|
||||
// If we have a single override called 'none', this disables all extensions.
|
||||
// Typically, this comes from the user passing `-e none`.
|
||||
if (
|
||||
this.enabledExtensionNamesOverride.length === 1 &&
|
||||
this.enabledExtensionNamesOverride[0] === 'none'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we have explicit overrides, only enable those extensions.
|
||||
if (this.enabledExtensionNamesOverride.length > 0) {
|
||||
// When checking against overrides ONLY, we use a case insensitive match.
|
||||
// The override names are already lowercased in the constructor.
|
||||
return this.enabledExtensionNamesOverride.includes(
|
||||
extensionName.toLocaleLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, we use the configuration settings
|
||||
const config = this.readConfig();
|
||||
const extensionConfig = config[extensionName];
|
||||
// Extensions are enabled by default.
|
||||
|
||||
@@ -8,12 +8,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
checkForExtensionUpdate,
|
||||
cloneFromGit,
|
||||
extractFile,
|
||||
findReleaseAsset,
|
||||
parseGitHubRepoForReleases,
|
||||
} from './github.js';
|
||||
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import type * as os from 'node:os';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as tar from 'tar';
|
||||
import * as archiver from 'archiver';
|
||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
|
||||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
@@ -306,6 +312,13 @@ describe('git extension helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail on a non-GitHub URL', () => {
|
||||
const source = 'https://example.com/owner/repo.git';
|
||||
expect(() => parseGitHubRepoForReleases(source)).toThrow(
|
||||
'Invalid GitHub repository source: https://example.com/owner/repo.git. Expected "owner/repo" or a github repo uri.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse owner and repo from a shorthand string', () => {
|
||||
const source = 'owner/repo';
|
||||
const { owner, repo } = parseGitHubRepoForReleases(source);
|
||||
@@ -334,4 +347,83 @@ describe('git extension helpers', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFile', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should extract a .tar.gz file', async () => {
|
||||
const archivePath = path.join(tempDir, 'test.tar.gz');
|
||||
const extractionDest = path.join(tempDir, 'extracted');
|
||||
await fs.mkdir(extractionDest);
|
||||
|
||||
// Create a dummy file to be archived
|
||||
const dummyFilePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(dummyFilePath, 'hello tar');
|
||||
|
||||
// Create the tar.gz file
|
||||
await tar.c(
|
||||
{
|
||||
gzip: true,
|
||||
file: archivePath,
|
||||
cwd: tempDir,
|
||||
},
|
||||
['test.txt'],
|
||||
);
|
||||
|
||||
await extractFile(archivePath, extractionDest);
|
||||
|
||||
const extractedFilePath = path.join(extractionDest, 'test.txt');
|
||||
const content = await fs.readFile(extractedFilePath, 'utf-8');
|
||||
expect(content).toBe('hello tar');
|
||||
});
|
||||
|
||||
it('should extract a .zip file', async () => {
|
||||
const archivePath = path.join(tempDir, 'test.zip');
|
||||
const extractionDest = path.join(tempDir, 'extracted');
|
||||
await fs.mkdir(extractionDest);
|
||||
|
||||
// Create a dummy file to be archived
|
||||
const dummyFilePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(dummyFilePath, 'hello zip');
|
||||
|
||||
// Create the zip file
|
||||
const output = fsSync.createWriteStream(archivePath);
|
||||
const archive = archiver.create('zip');
|
||||
|
||||
const streamFinished = new Promise((resolve, reject) => {
|
||||
output.on('close', () => resolve(null));
|
||||
archive.on('error', reject);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
archive.file(dummyFilePath, { name: 'test.txt' });
|
||||
await archive.finalize();
|
||||
await streamFinished;
|
||||
|
||||
await extractFile(archivePath, extractionDest);
|
||||
|
||||
const extractedFilePath = path.join(extractionDest, 'test.txt');
|
||||
const content = await fs.readFile(extractedFilePath, 'utf-8');
|
||||
expect(content).toBe('hello zip');
|
||||
});
|
||||
|
||||
it('should throw an error for unsupported file types', async () => {
|
||||
const unsupportedFilePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(unsupportedFilePath, 'some content');
|
||||
const extractionDest = path.join(tempDir, 'extracted');
|
||||
await fs.mkdir(extractionDest);
|
||||
|
||||
await expect(
|
||||
extractFile(unsupportedFilePath, extractionDest),
|
||||
).rejects.toThrow('Unsupported file extension for extraction:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,9 @@ import * as os from 'node:os';
|
||||
import * as https from 'node:https';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { loadExtension } from '../extension.js';
|
||||
import { quote } from 'shell-quote';
|
||||
import { EXTENSIONS_CONFIG_FILENAME, loadExtension } from '../extension.js';
|
||||
import * as tar from 'tar';
|
||||
import extract from 'extract-zip';
|
||||
|
||||
function getGitHubToken(): string | undefined {
|
||||
return process.env['GITHUB_TOKEN'];
|
||||
@@ -71,7 +71,7 @@ export async function cloneFromGit(
|
||||
await git.checkout('FETCH_HEAD');
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to clone Git repository from ${installMetadata.source}`,
|
||||
`Failed to clone Git repository from ${installMetadata.source} ${getErrorMessage(error)}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
@@ -87,7 +87,7 @@ export function parseGitHubRepoForReleases(source: string): {
|
||||
const parsedUrl = URL.parse(source, 'https://github.com');
|
||||
// The pathname should be "/owner/repo".
|
||||
const parts = parsedUrl?.pathname.substring(1).split('/');
|
||||
if (parts?.length !== 2) {
|
||||
if (parts?.length !== 2 || parsedUrl?.host !== 'github.com') {
|
||||
throw new Error(
|
||||
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
|
||||
);
|
||||
@@ -271,7 +271,35 @@ export async function downloadFromGitHubRelease(
|
||||
|
||||
await downloadFile(archiveUrl, downloadedAssetPath);
|
||||
|
||||
extractFile(downloadedAssetPath, destination);
|
||||
await extractFile(downloadedAssetPath, destination);
|
||||
|
||||
// For regular github releases, the repository is put inside of a top level
|
||||
// directory. In this case we should see exactly two file in the destination
|
||||
// dir, the archive and the directory. If we see that, validate that the
|
||||
// dir has a gemini extension configuration file and then move all files
|
||||
// from the directory up one level into the destination directory.
|
||||
const entries = await fs.promises.readdir(destination, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
if (entries.length === 2) {
|
||||
const lonelyDir = entries.find((entry) => entry.isDirectory());
|
||||
if (
|
||||
lonelyDir &&
|
||||
fs.existsSync(
|
||||
path.join(destination, lonelyDir.name, EXTENSIONS_CONFIG_FILENAME),
|
||||
)
|
||||
) {
|
||||
const dirPathToExtract = path.join(destination, lonelyDir.name);
|
||||
const extractedDirFiles = await fs.promises.readdir(dirPathToExtract);
|
||||
for (const file of extractedDirFiles) {
|
||||
await fs.promises.rename(
|
||||
path.join(dirPathToExtract, file),
|
||||
path.join(destination, file),
|
||||
);
|
||||
}
|
||||
await fs.promises.rmdir(dirPathToExtract);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.unlink(downloadedAssetPath);
|
||||
return {
|
||||
@@ -389,13 +417,14 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function extractFile(file: string, dest: string) {
|
||||
const safeFile = quote([file]);
|
||||
const safeDest = quote([dest]);
|
||||
export async function extractFile(file: string, dest: string): Promise<void> {
|
||||
if (file.endsWith('.tar.gz')) {
|
||||
execSync(`tar -xzf ${safeFile} -C ${safeDest}`);
|
||||
await tar.x({
|
||||
file,
|
||||
cwd: dest,
|
||||
});
|
||||
} else if (file.endsWith('.zip')) {
|
||||
execSync(`unzip ${safeFile} -d ${safeDest}`);
|
||||
await extract(file, { dir: dest });
|
||||
} else {
|
||||
throw new Error(`Unsupported file extension for extraction: ${file}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
ExtensionStorage,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
annotateActiveExtensions,
|
||||
loadExtension,
|
||||
@@ -19,6 +20,7 @@ import { GEMINI_DIR } from '@google/gemini-cli-core';
|
||||
import { isWorkspaceTrusted } from '../trustedFolders.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import { createExtension } from '../../test-utils/createExtension.js';
|
||||
import { ExtensionEnablementManager } from './extensionEnablement.js';
|
||||
|
||||
const mockGit = {
|
||||
clone: vi.fn(),
|
||||
@@ -87,7 +89,10 @@ describe('update tests', () => {
|
||||
// Clean up before each test
|
||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
Object.values(mockGit).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
@@ -131,12 +136,13 @@ describe('update tests', () => {
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
const updateInfo = await updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
async (_) => true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
() => {},
|
||||
);
|
||||
@@ -179,8 +185,7 @@ describe('update tests', () => {
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const setExtensionUpdateState = vi.fn();
|
||||
|
||||
const dispatch = vi.fn();
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
@@ -188,22 +193,31 @@ describe('update tests', () => {
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
await updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
async (_) => true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
setExtensionUpdateState,
|
||||
dispatch,
|
||||
);
|
||||
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATING,
|
||||
);
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.UPDATING,
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call setExtensionUpdateState with ERROR on failure', async () => {
|
||||
@@ -221,7 +235,7 @@ describe('update tests', () => {
|
||||
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const setExtensionUpdateState = vi.fn();
|
||||
const dispatch = vi.fn();
|
||||
const extension = annotateActiveExtensions(
|
||||
[
|
||||
loadExtension({
|
||||
@@ -229,24 +243,33 @@ describe('update tests', () => {
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
await expect(
|
||||
updateExtension(
|
||||
extension,
|
||||
tempHomeDir,
|
||||
async (_) => true,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
setExtensionUpdateState,
|
||||
dispatch,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.UPDATING,
|
||||
);
|
||||
expect(setExtensionUpdateState).toHaveBeenCalledWith(
|
||||
ExtensionUpdateState.ERROR,
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.UPDATING,
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extensionName,
|
||||
state: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,8 +291,8 @@ describe('update tests', () => {
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
@@ -278,20 +301,15 @@ describe('update tests', () => {
|
||||
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('localHash');
|
||||
|
||||
let extensionState = new Map();
|
||||
const results = await checkForAllExtensionUpdates(
|
||||
[extension],
|
||||
extensionState,
|
||||
(newState) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState(extensionState);
|
||||
} else {
|
||||
extensionState = newState;
|
||||
}
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'test-extension',
|
||||
state: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
},
|
||||
);
|
||||
const result = results.get('test-extension');
|
||||
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return UpToDate for a git extension with no updates', async () => {
|
||||
@@ -311,8 +329,8 @@ describe('update tests', () => {
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
@@ -321,20 +339,15 @@ describe('update tests', () => {
|
||||
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('sameHash');
|
||||
|
||||
let extensionState = new Map();
|
||||
const results = await checkForAllExtensionUpdates(
|
||||
[extension],
|
||||
extensionState,
|
||||
(newState) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState(extensionState);
|
||||
} else {
|
||||
extensionState = newState;
|
||||
}
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'test-extension',
|
||||
state: ExtensionUpdateState.UP_TO_DATE,
|
||||
},
|
||||
);
|
||||
const result = results.get('test-extension');
|
||||
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return UpToDate for a local extension with no updates', async () => {
|
||||
@@ -358,24 +371,18 @@ describe('update tests', () => {
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
let extensionState = new Map();
|
||||
const results = await checkForAllExtensionUpdates(
|
||||
[extension],
|
||||
extensionState,
|
||||
(newState) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState(extensionState);
|
||||
} else {
|
||||
extensionState = newState;
|
||||
}
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'local-extension',
|
||||
state: ExtensionUpdateState.UP_TO_DATE,
|
||||
},
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
const result = results.get('local-extension');
|
||||
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return UpdateAvailable for a local extension with updates', async () => {
|
||||
@@ -399,24 +406,18 @@ describe('update tests', () => {
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
let extensionState = new Map();
|
||||
const results = await checkForAllExtensionUpdates(
|
||||
[extension],
|
||||
extensionState,
|
||||
(newState) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState(extensionState);
|
||||
} else {
|
||||
extensionState = newState;
|
||||
}
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'local-extension',
|
||||
state: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
},
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
const result = results.get('local-extension');
|
||||
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return Error when git check fails', async () => {
|
||||
@@ -436,26 +437,21 @@ describe('update tests', () => {
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
})!,
|
||||
],
|
||||
[],
|
||||
process.cwd(),
|
||||
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
|
||||
)[0];
|
||||
|
||||
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
|
||||
|
||||
let extensionState = new Map();
|
||||
const results = await checkForAllExtensionUpdates(
|
||||
[extension],
|
||||
extensionState,
|
||||
(newState) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState(extensionState);
|
||||
} else {
|
||||
extensionState = newState;
|
||||
}
|
||||
const dispatch = vi.fn();
|
||||
await checkForAllExtensionUpdates([extension], dispatch);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: 'error-extension',
|
||||
state: ExtensionUpdateState.ERROR,
|
||||
},
|
||||
);
|
||||
const result = results.get('error-extension');
|
||||
expect(result).toBe(ExtensionUpdateState.ERROR);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
|
||||
import { type Dispatch, type SetStateAction } from 'react';
|
||||
import {
|
||||
type ExtensionUpdateAction,
|
||||
ExtensionUpdateState,
|
||||
type ExtensionUpdateStatus,
|
||||
} from '../../ui/state/extensions.js';
|
||||
import {
|
||||
copyExtension,
|
||||
installExtension,
|
||||
@@ -16,8 +16,12 @@ import {
|
||||
loadExtension,
|
||||
loadInstallMetadata,
|
||||
ExtensionStorage,
|
||||
loadExtensionConfig,
|
||||
} from '../extension.js';
|
||||
import { checkForExtensionUpdate } from './github.js';
|
||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
name: string;
|
||||
@@ -28,23 +32,33 @@ export interface ExtensionUpdateInfo {
|
||||
export async function updateExtension(
|
||||
extension: GeminiCLIExtension,
|
||||
cwd: string = process.cwd(),
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
currentState: ExtensionUpdateState,
|
||||
setExtensionUpdateState: (updateState: ExtensionUpdateState) => void,
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void,
|
||||
): Promise<ExtensionUpdateInfo | undefined> {
|
||||
if (currentState === ExtensionUpdateState.UPDATING) {
|
||||
return undefined;
|
||||
}
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATING);
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.UPDATING },
|
||||
});
|
||||
const installMetadata = loadInstallMetadata(extension.path);
|
||||
|
||||
if (!installMetadata?.type) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
throw new Error(
|
||||
`Extension ${extension.name} cannot be updated, type is unknown.`,
|
||||
);
|
||||
}
|
||||
if (installMetadata?.type === 'link') {
|
||||
setExtensionUpdateState(ExtensionUpdateState.UP_TO_DATE);
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.UP_TO_DATE },
|
||||
});
|
||||
throw new Error(`Extension is linked so does not need to be updated`);
|
||||
}
|
||||
const originalVersion = extension.version;
|
||||
@@ -52,8 +66,17 @@ export async function updateExtension(
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
await copyExtension(extension.path, tempDir);
|
||||
const previousExtensionConfig = await loadExtensionConfig({
|
||||
extensionDir: extension.path,
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
await uninstallExtension(extension.name, cwd);
|
||||
await installExtension(installMetadata, false, cwd);
|
||||
await installExtension(
|
||||
installMetadata,
|
||||
requestConsent,
|
||||
cwd,
|
||||
previousExtensionConfig,
|
||||
);
|
||||
|
||||
const updatedExtensionStorage = new ExtensionStorage(extension.name);
|
||||
const updatedExtension = loadExtension({
|
||||
@@ -61,11 +84,20 @@ export async function updateExtension(
|
||||
workspaceDir: cwd,
|
||||
});
|
||||
if (!updatedExtension) {
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
throw new Error('Updated extension not found after installation.');
|
||||
}
|
||||
const updatedVersion = updatedExtension.config.version;
|
||||
setExtensionUpdateState(ExtensionUpdateState.UPDATED_NEEDS_RESTART);
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extension.name,
|
||||
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
},
|
||||
});
|
||||
return {
|
||||
name: extension.name,
|
||||
originalVersion,
|
||||
@@ -75,7 +107,10 @@ export async function updateExtension(
|
||||
console.error(
|
||||
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
|
||||
);
|
||||
setExtensionUpdateState(ExtensionUpdateState.ERROR);
|
||||
dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
|
||||
});
|
||||
await copyExtension(tempDir, extension.path);
|
||||
throw e;
|
||||
} finally {
|
||||
@@ -85,32 +120,26 @@ export async function updateExtension(
|
||||
|
||||
export async function updateAllUpdatableExtensions(
|
||||
cwd: string = process.cwd(),
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
extensions: GeminiCLIExtension[],
|
||||
extensionsState: Map<string, ExtensionUpdateState>,
|
||||
setExtensionsUpdateState: Dispatch<
|
||||
SetStateAction<Map<string, ExtensionUpdateState>>
|
||||
>,
|
||||
extensionsState: Map<string, ExtensionUpdateStatus>,
|
||||
dispatch: (action: ExtensionUpdateAction) => void,
|
||||
): Promise<ExtensionUpdateInfo[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
extensions
|
||||
.filter(
|
||||
(extension) =>
|
||||
extensionsState.get(extension.name) ===
|
||||
extensionsState.get(extension.name)?.status ===
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
)
|
||||
.map((extension) =>
|
||||
updateExtension(
|
||||
extension,
|
||||
cwd,
|
||||
extensionsState.get(extension.name)!,
|
||||
(updateState) => {
|
||||
setExtensionsUpdateState((prev) => {
|
||||
const finalState = new Map(prev);
|
||||
finalState.set(extension.name, updateState);
|
||||
return finalState;
|
||||
});
|
||||
},
|
||||
requestConsent,
|
||||
extensionsState.get(extension.name)!.status,
|
||||
dispatch,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -124,38 +153,30 @@ export interface ExtensionUpdateCheckResult {
|
||||
|
||||
export async function checkForAllExtensionUpdates(
|
||||
extensions: GeminiCLIExtension[],
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateState>,
|
||||
setExtensionsUpdateState: Dispatch<
|
||||
SetStateAction<Map<string, ExtensionUpdateState>>
|
||||
>,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<Map<string, ExtensionUpdateState>> {
|
||||
dispatch: (action: ExtensionUpdateAction) => void,
|
||||
): Promise<void> {
|
||||
dispatch({ type: 'BATCH_CHECK_START' });
|
||||
const promises: Array<Promise<void>> = [];
|
||||
for (const extension of extensions) {
|
||||
const initialState = extensionsUpdateState.get(extension.name);
|
||||
if (initialState === undefined) {
|
||||
if (!extension.installMetadata) {
|
||||
setExtensionsUpdateState((prev) => {
|
||||
extensionsUpdateState = new Map(prev);
|
||||
extensionsUpdateState.set(
|
||||
extension.name,
|
||||
ExtensionUpdateState.NOT_UPDATABLE,
|
||||
);
|
||||
return extensionsUpdateState;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await checkForExtensionUpdate(
|
||||
extension,
|
||||
(updatedState) => {
|
||||
setExtensionsUpdateState((prev) => {
|
||||
extensionsUpdateState = new Map(prev);
|
||||
extensionsUpdateState.set(extension.name, updatedState);
|
||||
return extensionsUpdateState;
|
||||
});
|
||||
if (!extension.installMetadata) {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: {
|
||||
name: extension.name,
|
||||
state: ExtensionUpdateState.NOT_UPDATABLE,
|
||||
},
|
||||
cwd,
|
||||
);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
promises.push(
|
||||
checkForExtensionUpdate(extension, (updatedState) => {
|
||||
dispatch({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extension.name, state: updatedState },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
return extensionsUpdateState;
|
||||
await Promise.all(promises);
|
||||
dispatch({ type: 'BATCH_CHECK_END' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ApprovalMode,
|
||||
PolicyDecision,
|
||||
PolicyEngine,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createPolicyEngineConfig } from './policy.js';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
describe('Policy Engine Integration Tests', () => {
|
||||
describe('Policy configuration produces valid PolicyEngine config', () => {
|
||||
it('should create a working PolicyEngine from basic settings', () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
allowed: ['run_shell_command'],
|
||||
exclude: ['write_file'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Allowed tool should be allowed
|
||||
expect(engine.check({ name: 'run_shell_command' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
// Excluded tool should be denied
|
||||
expect(engine.check({ name: 'write_file' })).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Other write tools should ask user
|
||||
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ASK_USER);
|
||||
|
||||
// Unknown tools should use default
|
||||
expect(engine.check({ name: 'unknown_tool' })).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle MCP server wildcard patterns correctly', () => {
|
||||
const settings: Settings = {
|
||||
mcp: {
|
||||
allowed: ['allowed-server'],
|
||||
excluded: ['blocked-server'],
|
||||
},
|
||||
mcpServers: {
|
||||
'trusted-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Tools from allowed server should be allowed
|
||||
expect(engine.check({ name: 'allowed-server__tool1' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(engine.check({ name: 'allowed-server__another_tool' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
// Tools from trusted server should be allowed
|
||||
expect(engine.check({ name: 'trusted-server__tool1' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(engine.check({ name: 'trusted-server__special_tool' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
// Tools from blocked server should be denied
|
||||
expect(engine.check({ name: 'blocked-server__tool1' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
expect(engine.check({ name: 'blocked-server__any_tool' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
|
||||
// Tools from unknown servers should use default
|
||||
expect(engine.check({ name: 'unknown-server__tool' })).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly prioritize specific tool rules over MCP server wildcards', () => {
|
||||
const settings: Settings = {
|
||||
mcp: {
|
||||
allowed: ['my-server'],
|
||||
},
|
||||
tools: {
|
||||
exclude: ['my-server__dangerous-tool'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Server is allowed, but specific tool is excluded
|
||||
expect(engine.check({ name: 'my-server__safe-tool' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(engine.check({ name: 'my-server__dangerous-tool' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex mixed configurations', () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Allows read-only tools
|
||||
allowed: ['custom-tool', 'my-server__special-tool'],
|
||||
exclude: ['glob', 'dangerous-tool'],
|
||||
},
|
||||
mcp: {
|
||||
allowed: ['allowed-server'],
|
||||
excluded: ['blocked-server'],
|
||||
},
|
||||
mcpServers: {
|
||||
'trusted-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Read-only tools should be allowed (autoAccept)
|
||||
expect(engine.check({ name: 'read_file' })).toBe(PolicyDecision.ALLOW);
|
||||
expect(engine.check({ name: 'list_directory' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
// But glob is explicitly excluded, so it should be denied
|
||||
expect(engine.check({ name: 'glob' })).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Replace should ask user (normal write tool behavior)
|
||||
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ASK_USER);
|
||||
|
||||
// Explicitly allowed tools
|
||||
expect(engine.check({ name: 'custom-tool' })).toBe(PolicyDecision.ALLOW);
|
||||
expect(engine.check({ name: 'my-server__special-tool' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
// MCP server tools
|
||||
expect(engine.check({ name: 'allowed-server__tool' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(engine.check({ name: 'trusted-server__tool' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(engine.check({ name: 'blocked-server__tool' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
|
||||
// Write tools should ask by default
|
||||
expect(engine.check({ name: 'write_file' })).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle YOLO mode correctly', () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
exclude: ['dangerous-tool'], // Even in YOLO, excludes should be respected
|
||||
},
|
||||
};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Most tools should be allowed in YOLO mode
|
||||
expect(engine.check({ name: 'run_shell_command' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(engine.check({ name: 'write_file' })).toBe(PolicyDecision.ALLOW);
|
||||
expect(engine.check({ name: 'unknown_tool' })).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// But explicitly excluded tools should still be denied
|
||||
expect(engine.check({ name: 'dangerous-tool' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle AUTO_EDIT mode correctly', () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Edit tool should be allowed (EditTool.Name = 'replace')
|
||||
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Other tools should follow normal rules
|
||||
expect(engine.check({ name: 'run_shell_command' })).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
expect(engine.check({ name: 'write_file' })).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should verify priority ordering works correctly in practice', () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Priority 50
|
||||
allowed: ['specific-tool'], // Priority 100
|
||||
exclude: ['blocked-tool'], // Priority 200
|
||||
},
|
||||
mcp: {
|
||||
allowed: ['mcp-server'], // Priority 85
|
||||
excluded: ['blocked-server'], // Priority 195
|
||||
},
|
||||
mcpServers: {
|
||||
'trusted-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: true, // Priority 90
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Test that priorities are applied correctly
|
||||
const rules = config.rules || [];
|
||||
|
||||
// Find rules and verify their priorities
|
||||
const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool');
|
||||
expect(blockedToolRule?.priority).toBe(200);
|
||||
|
||||
const blockedServerRule = rules.find(
|
||||
(r) => r.toolName === 'blocked-server__*',
|
||||
);
|
||||
expect(blockedServerRule?.priority).toBe(195);
|
||||
|
||||
const specificToolRule = rules.find(
|
||||
(r) => r.toolName === 'specific-tool',
|
||||
);
|
||||
expect(specificToolRule?.priority).toBe(100);
|
||||
|
||||
const trustedServerRule = rules.find(
|
||||
(r) => r.toolName === 'trusted-server__*',
|
||||
);
|
||||
expect(trustedServerRule?.priority).toBe(90);
|
||||
|
||||
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
|
||||
expect(mcpServerRule?.priority).toBe(85);
|
||||
|
||||
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
|
||||
expect(readOnlyToolRule?.priority).toBe(50);
|
||||
|
||||
// Verify the engine applies these priorities correctly
|
||||
expect(engine.check({ name: 'blocked-tool' })).toBe(PolicyDecision.DENY);
|
||||
expect(engine.check({ name: 'blocked-server__any' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
expect(engine.check({ name: 'specific-tool' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(engine.check({ name: 'trusted-server__any' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(engine.check({ name: 'mcp-server__any' })).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(engine.check({ name: 'glob' })).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should handle edge case: MCP server with both trust and exclusion', () => {
|
||||
const settings: Settings = {
|
||||
mcpServers: {
|
||||
'conflicted-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: true, // Priority 90 - ALLOW
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
excluded: ['conflicted-server'], // Priority 195 - DENY
|
||||
},
|
||||
};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Exclusion (195) should win over trust (90)
|
||||
expect(engine.check({ name: 'conflicted-server__tool' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle edge case: specific tool allowed but server excluded', () => {
|
||||
const settings: Settings = {
|
||||
mcp: {
|
||||
excluded: ['my-server'], // Priority 195 - DENY
|
||||
},
|
||||
tools: {
|
||||
allowed: ['my-server__special-tool'], // Priority 100 - ALLOW
|
||||
},
|
||||
};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Server exclusion (195) wins over specific tool allow (100)
|
||||
// This might be counterintuitive but follows the priority system
|
||||
expect(engine.check({ name: 'my-server__special-tool' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
expect(engine.check({ name: 'my-server__other-tool' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should verify non-interactive mode transformation', () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
// Enable non-interactive mode
|
||||
const engineConfig = { ...config, nonInteractive: true };
|
||||
const engine = new PolicyEngine(engineConfig);
|
||||
|
||||
// ASK_USER should become DENY in non-interactive mode
|
||||
expect(engine.check({ name: 'unknown_tool' })).toBe(PolicyDecision.DENY);
|
||||
expect(engine.check({ name: 'run_shell_command' })).toBe(
|
||||
PolicyDecision.DENY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty settings gracefully', () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Should have default rules for write tools
|
||||
expect(engine.check({ name: 'write_file' })).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ASK_USER);
|
||||
|
||||
// Unknown tools should use default
|
||||
expect(engine.check({ name: 'unknown' })).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should verify rules are created with correct priorities', () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true,
|
||||
allowed: ['tool1', 'tool2'],
|
||||
exclude: ['tool3'],
|
||||
},
|
||||
mcp: {
|
||||
allowed: ['server1'],
|
||||
excluded: ['server2'],
|
||||
},
|
||||
};
|
||||
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const rules = config.rules || [];
|
||||
|
||||
// Verify each rule has the expected priority
|
||||
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
|
||||
expect(tool3Rule?.priority).toBe(200); // Excluded tools
|
||||
|
||||
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
|
||||
expect(server2Rule?.priority).toBe(195); // Excluded servers
|
||||
|
||||
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
|
||||
expect(tool1Rule?.priority).toBe(100); // Allowed tools
|
||||
|
||||
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
|
||||
expect(server1Rule?.priority).toBe(85); // Allowed servers
|
||||
|
||||
const globRule = rules.find((r) => r.toolName === 'glob');
|
||||
expect(globRule?.priority).toBe(50); // Auto-accept read-only
|
||||
|
||||
// The PolicyEngine will sort these by priority when it's created
|
||||
const engine = new PolicyEngine(config);
|
||||
const sortedRules = engine.getRules();
|
||||
|
||||
// Verify the engine sorted them correctly
|
||||
for (let i = 1; i < sortedRules.length; i++) {
|
||||
const prevPriority = sortedRules[i - 1].priority ?? 0;
|
||||
const currPriority = sortedRules[i].priority ?? 0;
|
||||
expect(prevPriority).toBeGreaterThanOrEqual(currPriority);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createPolicyEngineConfig } from './policy.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import { ApprovalMode, PolicyDecision } from '@google/gemini-cli-core';
|
||||
|
||||
describe('createPolicyEngineConfig', () => {
|
||||
it('should return ASK_USER for all tools by default', () => {
|
||||
const settings: Settings = {};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
expect(config.defaultDecision).toBe(PolicyDecision.ASK_USER);
|
||||
expect(config.rules).toEqual([
|
||||
{ toolName: 'replace', decision: 'ask_user', priority: 10 },
|
||||
{ toolName: 'save_memory', decision: 'ask_user', priority: 10 },
|
||||
{ toolName: 'run_shell_command', decision: 'ask_user', priority: 10 },
|
||||
{ toolName: 'write_file', decision: 'ask_user', priority: 10 },
|
||||
{ toolName: 'web_fetch', decision: 'ask_user', priority: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should allow tools in tools.allowed', () => {
|
||||
const settings: Settings = {
|
||||
tools: { allowed: ['run_shell_command'] },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const rule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'run_shell_command' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBe(100);
|
||||
});
|
||||
|
||||
it('should deny tools in tools.exclude', () => {
|
||||
const settings: Settings = {
|
||||
tools: { exclude: ['run_shell_command'] },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const rule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'run_shell_command' &&
|
||||
r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBe(200);
|
||||
});
|
||||
|
||||
it('should allow tools from allowed MCP servers', () => {
|
||||
const settings: Settings = {
|
||||
mcp: { allowed: ['my-server'] },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const rule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBe(85);
|
||||
});
|
||||
|
||||
it('should deny tools from excluded MCP servers', () => {
|
||||
const settings: Settings = {
|
||||
mcp: { excluded: ['my-server'] },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const rule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBe(195);
|
||||
});
|
||||
|
||||
it('should allow tools from trusted MCP servers', () => {
|
||||
const settings: Settings = {
|
||||
mcpServers: {
|
||||
'trusted-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: true,
|
||||
},
|
||||
'untrusted-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
|
||||
const trustedRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'trusted-server__*' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(trustedRule).toBeDefined();
|
||||
expect(trustedRule?.priority).toBe(90);
|
||||
|
||||
// Untrusted server should not have an allow rule
|
||||
const untrustedRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'untrusted-server__*' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(untrustedRule).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple MCP server configurations together', () => {
|
||||
const settings: Settings = {
|
||||
mcp: {
|
||||
allowed: ['allowed-server'],
|
||||
excluded: ['excluded-server'],
|
||||
},
|
||||
mcpServers: {
|
||||
'trusted-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
|
||||
// Check allowed server
|
||||
const allowedRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'allowed-server__*' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(allowedRule).toBeDefined();
|
||||
expect(allowedRule?.priority).toBe(85);
|
||||
|
||||
// Check trusted server
|
||||
const trustedRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'trusted-server__*' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(trustedRule).toBeDefined();
|
||||
expect(trustedRule?.priority).toBe(90);
|
||||
|
||||
// Check excluded server
|
||||
const excludedRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'excluded-server__*' &&
|
||||
r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
expect(excludedRule).toBeDefined();
|
||||
expect(excludedRule?.priority).toBe(195);
|
||||
});
|
||||
|
||||
it('should allow read-only tools if autoAccept is true', () => {
|
||||
const settings: Settings = {
|
||||
tools: { autoAccept: true },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const rule = config.rules?.find(
|
||||
(r) => r.toolName === 'glob' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBe(50);
|
||||
});
|
||||
|
||||
it('should allow all tools in YOLO mode', () => {
|
||||
const settings: Settings = {};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
|
||||
const rule = config.rules?.find(
|
||||
(r) => r.decision === PolicyDecision.ALLOW && r.priority === 0,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow edit tool in AUTO_EDIT mode', () => {
|
||||
const settings: Settings = {};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT);
|
||||
const rule = config.rules?.find(
|
||||
(r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBe(15);
|
||||
});
|
||||
|
||||
it('should prioritize exclude over allow', () => {
|
||||
const settings: Settings = {
|
||||
tools: { allowed: ['run_shell_command'], exclude: ['run_shell_command'] },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
const denyRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'run_shell_command' &&
|
||||
r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
const allowRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'run_shell_command' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(denyRule).toBeDefined();
|
||||
expect(allowRule).toBeDefined();
|
||||
expect(denyRule!.priority).toBeGreaterThan(allowRule!.priority!);
|
||||
});
|
||||
|
||||
it('should prioritize specific tool allows over MCP server excludes', () => {
|
||||
const settings: Settings = {
|
||||
mcp: { excluded: ['my-server'] },
|
||||
tools: { allowed: ['my-server__specific-tool'] },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
|
||||
const serverDenyRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
const toolAllowRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'my-server__specific-tool' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
expect(serverDenyRule).toBeDefined();
|
||||
expect(serverDenyRule?.priority).toBe(195);
|
||||
expect(toolAllowRule).toBeDefined();
|
||||
expect(toolAllowRule?.priority).toBe(100);
|
||||
|
||||
// Tool allow (100) has lower priority than server deny (195),
|
||||
// so server deny wins - this might be counterintuitive
|
||||
});
|
||||
|
||||
it('should prioritize specific tool excludes over MCP server allows', () => {
|
||||
const settings: Settings = {
|
||||
mcp: { allowed: ['my-server'] },
|
||||
mcpServers: {
|
||||
'my-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: true,
|
||||
},
|
||||
},
|
||||
tools: { exclude: ['my-server__dangerous-tool'] },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
|
||||
const serverAllowRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
const toolDenyRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'my-server__dangerous-tool' &&
|
||||
r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
|
||||
expect(serverAllowRule).toBeDefined();
|
||||
expect(toolDenyRule).toBeDefined();
|
||||
expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!);
|
||||
});
|
||||
|
||||
it('should handle complex priority scenarios correctly', () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Priority 50 for read-only tools
|
||||
allowed: ['my-server__tool1', 'other-tool'], // Priority 100
|
||||
exclude: ['my-server__tool2', 'glob'], // Priority 200
|
||||
},
|
||||
mcp: {
|
||||
allowed: ['allowed-server'], // Priority 85
|
||||
excluded: ['excluded-server'], // Priority 195
|
||||
},
|
||||
mcpServers: {
|
||||
'trusted-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: true, // Priority 90
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
|
||||
// Verify glob is denied even though autoAccept would allow it
|
||||
const globDenyRule = config.rules?.find(
|
||||
(r) => r.toolName === 'glob' && r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
const globAllowRule = config.rules?.find(
|
||||
(r) => r.toolName === 'glob' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(globDenyRule).toBeDefined();
|
||||
expect(globAllowRule).toBeDefined();
|
||||
expect(globDenyRule!.priority).toBe(200);
|
||||
expect(globAllowRule!.priority).toBe(50);
|
||||
|
||||
// Verify all priority levels are correct
|
||||
const priorities = config.rules
|
||||
?.map((r) => ({
|
||||
tool: r.toolName,
|
||||
decision: r.decision,
|
||||
priority: r.priority,
|
||||
}))
|
||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
|
||||
// Check that the highest priority items are the excludes
|
||||
const highestPriorityExcludes = priorities?.filter(
|
||||
(p) => p.priority === 200,
|
||||
);
|
||||
expect(
|
||||
highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle MCP servers with undefined trust property', () => {
|
||||
const settings: Settings = {
|
||||
mcpServers: {
|
||||
'no-trust-property': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
// trust property is undefined/missing
|
||||
},
|
||||
'explicit-false': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
|
||||
// Neither server should have an allow rule
|
||||
const noTrustRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'no-trust-property__*' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
const explicitFalseRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'explicit-false__*' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
expect(noTrustRule).toBeUndefined();
|
||||
expect(explicitFalseRule).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not add write tool rules in YOLO mode', () => {
|
||||
const settings: Settings = {
|
||||
tools: { exclude: ['dangerous-tool'] },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
|
||||
|
||||
// Should have the wildcard allow rule with priority 0
|
||||
const wildcardRule = config.rules?.find(
|
||||
(r) =>
|
||||
!r.toolName && r.decision === PolicyDecision.ALLOW && r.priority === 0,
|
||||
);
|
||||
expect(wildcardRule).toBeDefined();
|
||||
|
||||
// Should NOT have any write tool rules (which would have priority 10)
|
||||
const writeToolRules = config.rules?.filter(
|
||||
(r) =>
|
||||
[
|
||||
'replace',
|
||||
'save_memory',
|
||||
'run_shell_command',
|
||||
'write_file',
|
||||
'web_fetch',
|
||||
].includes(r.toolName || '') && r.decision === PolicyDecision.ASK_USER,
|
||||
);
|
||||
expect(writeToolRules).toHaveLength(0);
|
||||
|
||||
// Should still have the exclude rule
|
||||
const excludeRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
expect(excludeRule).toBeDefined();
|
||||
expect(excludeRule?.priority).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle combination of trusted server and excluded server for same name', () => {
|
||||
const settings: Settings = {
|
||||
mcpServers: {
|
||||
'conflicted-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
trust: true, // Priority 90
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
excluded: ['conflicted-server'], // Priority 195
|
||||
},
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
|
||||
// Both rules should exist
|
||||
const trustRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'conflicted-server__*' &&
|
||||
r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
const excludeRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'conflicted-server__*' &&
|
||||
r.decision === PolicyDecision.DENY,
|
||||
);
|
||||
|
||||
expect(trustRule).toBeDefined();
|
||||
expect(trustRule?.priority).toBe(90);
|
||||
expect(excludeRule).toBeDefined();
|
||||
expect(excludeRule?.priority).toBe(195);
|
||||
|
||||
// Exclude (195) should win over trust (90) when evaluated
|
||||
});
|
||||
|
||||
it('should create all read-only tool rules when autoAccept is enabled', () => {
|
||||
const settings: Settings = {
|
||||
tools: { autoAccept: true },
|
||||
};
|
||||
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||
|
||||
// All read-only tools should have allow rules
|
||||
const readOnlyTools = [
|
||||
'glob',
|
||||
'search_file_content',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'read_many_files',
|
||||
];
|
||||
for (const tool of readOnlyTools) {
|
||||
const rule = config.rules?.find(
|
||||
(r) => r.toolName === tool && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.priority).toBe(50);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle all approval modes correctly', () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
// Test DEFAULT mode
|
||||
const defaultConfig = createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
expect(defaultConfig.defaultDecision).toBe(PolicyDecision.ASK_USER);
|
||||
expect(
|
||||
defaultConfig.rules?.find(
|
||||
(r) => !r.toolName && r.decision === PolicyDecision.ALLOW,
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
// Test YOLO mode
|
||||
const yoloConfig = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
|
||||
expect(yoloConfig.defaultDecision).toBe(PolicyDecision.ASK_USER);
|
||||
const yoloWildcard = yoloConfig.rules?.find(
|
||||
(r) => !r.toolName && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(yoloWildcard).toBeDefined();
|
||||
expect(yoloWildcard?.priority).toBe(0);
|
||||
|
||||
// Test AUTO_EDIT mode
|
||||
const autoEditConfig = createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
expect(autoEditConfig.defaultDecision).toBe(PolicyDecision.ASK_USER);
|
||||
const editRule = autoEditConfig.rules?.find(
|
||||
(r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(editRule).toBeDefined();
|
||||
expect(editRule?.priority).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type PolicyEngineConfig,
|
||||
PolicyDecision,
|
||||
type PolicyRule,
|
||||
ApprovalMode,
|
||||
// Read-only tools
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
LSTool,
|
||||
ReadFileTool,
|
||||
ReadManyFilesTool,
|
||||
RipGrepTool,
|
||||
// Write tools
|
||||
EditTool,
|
||||
MemoryTool,
|
||||
ShellTool,
|
||||
WriteFileTool,
|
||||
WebFetchTool,
|
||||
WebSearchTool,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
// READ_ONLY_TOOLS is a list of built-in tools that do not modify the user's
|
||||
// files or system state.
|
||||
const READ_ONLY_TOOLS = new Set([
|
||||
GlobTool.Name,
|
||||
GrepTool.Name,
|
||||
RipGrepTool.Name,
|
||||
LSTool.Name,
|
||||
ReadFileTool.Name,
|
||||
ReadManyFilesTool.Name,
|
||||
WebSearchTool.Name,
|
||||
]);
|
||||
|
||||
// WRITE_TOOLS is a list of built-in tools that can modify the user's files or
|
||||
// system state. These tools have a shouldConfirmExecute method.
|
||||
// We are keeping this here for visibility and to maintain backwards compatibility
|
||||
// with the existing tool permissions system. Eventually we'll remove this and
|
||||
// any tool that isn't read only will require a confirmation unless altered by
|
||||
// config and policy.
|
||||
const WRITE_TOOLS = new Set([
|
||||
EditTool.Name,
|
||||
MemoryTool.Name,
|
||||
ShellTool.Name,
|
||||
WriteFileTool.Name,
|
||||
WebFetchTool.Name,
|
||||
]);
|
||||
|
||||
export function createPolicyEngineConfig(
|
||||
settings: Settings,
|
||||
approvalMode: ApprovalMode,
|
||||
): PolicyEngineConfig {
|
||||
const rules: PolicyRule[] = [];
|
||||
|
||||
// Priority system for policy rules:
|
||||
// - Higher priority numbers win over lower priority numbers
|
||||
// - When multiple rules match, the highest priority rule is applied
|
||||
// - Rules are evaluated in order of priority (highest first)
|
||||
//
|
||||
// Priority levels used in this configuration:
|
||||
// 0: Default allow-all (YOLO mode only)
|
||||
// 10: Write tools default to ASK_USER
|
||||
// 50: Auto-accept read-only tools
|
||||
// 85: MCP servers allowed list
|
||||
// 90: MCP servers with trust=true
|
||||
// 100: Explicitly allowed individual tools
|
||||
// 195: Explicitly excluded MCP servers
|
||||
// 200: Explicitly excluded individual tools (highest priority)
|
||||
|
||||
// MCP servers that are explicitly allowed in settings.mcp.allowed
|
||||
// Priority: 85 (lower than trusted servers)
|
||||
if (settings.mcp?.allowed) {
|
||||
for (const serverName of settings.mcp.allowed) {
|
||||
rules.push({
|
||||
toolName: `${serverName}__*`,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 85,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// MCP servers that are trusted in the settings.
|
||||
// Priority: 90 (higher than general allowed servers but lower than explicit tool allows)
|
||||
if (settings.mcpServers) {
|
||||
for (const [serverName, serverConfig] of Object.entries(
|
||||
settings.mcpServers,
|
||||
)) {
|
||||
if (serverConfig.trust) {
|
||||
// Trust all tools from this MCP server
|
||||
// Using pattern matching for MCP tool names which are formatted as "serverName__toolName"
|
||||
rules.push({
|
||||
toolName: `${serverName}__*`,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 90,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tools that are explicitly allowed in the settings.
|
||||
// Priority: 100
|
||||
if (settings.tools?.allowed) {
|
||||
for (const tool of settings.tools.allowed) {
|
||||
rules.push({
|
||||
toolName: tool,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tools that are explicitly excluded in the settings.
|
||||
// Priority: 200
|
||||
if (settings.tools?.exclude) {
|
||||
for (const tool of settings.tools.exclude) {
|
||||
rules.push({
|
||||
toolName: tool,
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// MCP servers that are explicitly excluded in settings.mcp.excluded
|
||||
// Priority: 195 (high priority to block servers)
|
||||
if (settings.mcp?.excluded) {
|
||||
for (const serverName of settings.mcp.excluded) {
|
||||
rules.push({
|
||||
toolName: `${serverName}__*`,
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 195,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If auto-accept is enabled, allow all read-only tools.
|
||||
// Priority: 50
|
||||
if (settings.tools?.autoAccept) {
|
||||
for (const tool of READ_ONLY_TOOLS) {
|
||||
rules.push({
|
||||
toolName: tool,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 50,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only add write tool rules if not in YOLO mode
|
||||
// In YOLO mode, the wildcard ALLOW rule handles everything
|
||||
if (approvalMode !== ApprovalMode.YOLO) {
|
||||
for (const tool of WRITE_TOOLS) {
|
||||
rules.push({
|
||||
toolName: tool,
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (approvalMode === ApprovalMode.YOLO) {
|
||||
rules.push({
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 0, // Lowest priority - catches everything not explicitly configured
|
||||
});
|
||||
} else if (approvalMode === ApprovalMode.AUTO_EDIT) {
|
||||
rules.push({
|
||||
toolName: EditTool.Name,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 15, // Higher than write tools (10) to override ASK_USER
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rules,
|
||||
defaultDecision: PolicyDecision.ASK_USER,
|
||||
};
|
||||
}
|
||||
@@ -30,7 +30,9 @@ vi.mock('./settings.js', async (importActual) => {
|
||||
|
||||
// Mock trustedFolders
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi.fn(),
|
||||
isWorkspaceTrusted: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isTrusted: true, source: 'file' }),
|
||||
}));
|
||||
|
||||
// NOW import everything else, including the (now effectively re-exported) settings.js
|
||||
@@ -120,7 +122,10 @@ describe('Settings Loading and Merging', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false);
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON
|
||||
(mockFsMkdirSync as Mock).mockImplementation(() => undefined);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1843,7 +1848,10 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
|
||||
it('should NOT merge workspace settings when workspace is not trusted', () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(false);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
ui: { theme: 'dark' },
|
||||
@@ -2200,6 +2208,18 @@ describe('Settings Loading and Merging', () => {
|
||||
allowMCPServers: ['serverA'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly migrate customWittyPhrases', () => {
|
||||
const v2Settings: Partial<Settings> = {
|
||||
ui: {
|
||||
customWittyPhrases: ['test phrase'],
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings as Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
customWittyPhrases: ['test phrase'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadEnvironment', () => {
|
||||
@@ -2210,7 +2230,10 @@ describe('Settings Loading and Merging', () => {
|
||||
delete process.env['TESTTEST']; // reset
|
||||
const geminiEnvPath = path.resolve(path.join(GEMINI_DIR, '.env'));
|
||||
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(isWorkspaceTrustedValue);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: isWorkspaceTrustedValue,
|
||||
source: 'file',
|
||||
});
|
||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
|
||||
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
|
||||
);
|
||||
@@ -2258,7 +2281,7 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
|
||||
it('should return false for settings that are already in V2 format', () => {
|
||||
const v2Settings = {
|
||||
const v2Settings: Partial<Settings> = {
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
getSettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge } from '../utils/deepMerge.js';
|
||||
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import { disableExtension } from './extension.js';
|
||||
|
||||
@@ -69,10 +69,12 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
coreTools: 'tools.core',
|
||||
contextFileName: 'context.fileName',
|
||||
customThemes: 'ui.customThemes',
|
||||
customWittyPhrases: 'ui.customWittyPhrases',
|
||||
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
|
||||
disableAutoUpdate: 'general.disableAutoUpdate',
|
||||
disableUpdateNag: 'general.disableUpdateNag',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enableMessageBusIntegration: 'tools.enableMessageBusIntegration',
|
||||
enablePromptCompletion: 'general.enablePromptCompletion',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
@@ -85,6 +87,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
folderTrust: 'security.folderTrust.enabled',
|
||||
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
|
||||
hideWindowTitle: 'ui.hideWindowTitle',
|
||||
showStatusInTitle: 'ui.showStatusInTitle',
|
||||
hideTips: 'ui.hideTips',
|
||||
hideBanner: 'ui.hideBanner',
|
||||
hideFooter: 'ui.hideFooter',
|
||||
@@ -252,7 +255,28 @@ function migrateSettingsToV2(
|
||||
|
||||
// Carry over any unrecognized keys
|
||||
for (const remainingKey of flatKeys) {
|
||||
v2Settings[remainingKey] = flatSettings[remainingKey];
|
||||
const existingValue = v2Settings[remainingKey];
|
||||
const newValue = flatSettings[remainingKey];
|
||||
|
||||
if (
|
||||
typeof existingValue === 'object' &&
|
||||
existingValue !== null &&
|
||||
!Array.isArray(existingValue) &&
|
||||
typeof newValue === 'object' &&
|
||||
newValue !== null &&
|
||||
!Array.isArray(newValue)
|
||||
) {
|
||||
const pathAwareGetStrategy = (path: string[]) =>
|
||||
getMergeStrategyForPath([remainingKey, ...path]);
|
||||
v2Settings[remainingKey] = customDeepMerge(
|
||||
pathAwareGetStrategy,
|
||||
{},
|
||||
newValue as MergeableObject,
|
||||
existingValue as MergeableObject,
|
||||
);
|
||||
} else {
|
||||
v2Settings[remainingKey] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
return v2Settings;
|
||||
@@ -470,7 +494,7 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void {
|
||||
export function loadEnvironment(settings: Settings): void {
|
||||
const envFilePath = findEnvFile(process.cwd());
|
||||
|
||||
if (!isWorkspaceTrusted(settings)) {
|
||||
if (!isWorkspaceTrusted(settings).isTrusted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -652,7 +676,7 @@ export function loadSettings(
|
||||
userSettings,
|
||||
);
|
||||
const isTrusted =
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings) ?? true;
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
|
||||
|
||||
// Create a temporary merged settings object to pass to loadEnvironment.
|
||||
const tempMergedSettings = mergeSettings(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user