mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 05:01:04 -07:00
Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b56a111594 | |||
| 48ee9bb308 | |||
| 4b2701195a | |||
| df72e3af3d | |||
| 334b813d81 | |||
| 314f67a326 | |||
| 0f0d1d8fc0 | |||
| cb15a238fe | |||
| 59a18e710d | |||
| 5f027cb63a | |||
| 4086abf375 | |||
| 8f5bf33eac | |||
| 7eeb7bd74c | |||
| 96b9be3ec4 | |||
| 1e31427da8 | |||
| 7feb2f8f42 | |||
| 2122604b32 | |||
| d4b4aede2f | |||
| 4b5c044272 | |||
| 2fe45834dd | |||
| 56092bd782 | |||
| c31f05356a | |||
| 61dbab03e0 | |||
| 86b5995f12 | |||
| d2849fda8a | |||
| a61fb058b7 | |||
| 7edd803034 | |||
| 0c54136244 | |||
| cce4574143 | |||
| 9172e28315 | |||
| 6f4b2ad0b9 | |||
| 9a3ff6510f | |||
| 4c67eef0f2 | |||
| 7d4f97de7a | |||
| cbd2eee9c4 | |||
| 384fb6a465 | |||
| 2cb33b2f76 | |||
| 8f0324d868 | |||
| fd7b6bf40a | |||
| ed8bad8c26 | |||
| 6d1e27633a | |||
| 2da911e4a0 | |||
| e5d183031a | |||
| b13c6b57ae | |||
| 3c92666ec2 | |||
| dc6dda5c37 | |||
| 3997c7ff80 | |||
| b4b49e7029 | |||
| 615b218ff7 | |||
| f3625aab13 | |||
| d3563e2f0e | |||
| dd84c2fb83 | |||
| bdb349e7f6 | |||
| 12c7c9cc42 | |||
| 90be9c3587 | |||
| eec5d5ebf8 | |||
| d3c206c677 | |||
| f0a039f7c0 | |||
| 30f5c4af4a | |||
| e78c3fe4f0 | |||
| c0ccb22460 | |||
| 764b195977 | |||
| 0f3555a4d2 | |||
| 006de1dd31 | |||
| 18fef0db31 | |||
| 958284dc24 | |||
| 788bb04f5c | |||
| 8a0190ca3b | |||
| c29a8c12b3 | |||
| 05049b5abf | |||
| 3ebe4e6a8f | |||
| 0eb84f5133 | |||
| de1233b8ca | |||
| ec11b8afbf | |||
| ec79fe1ab2 | |||
| 90eb1e0281 | |||
| 15c9f88da6 | |||
| 4e6fee7fcd | |||
| 1e08b150f7 | |||
| fb22f5b8ee | |||
| 848e8485cd | |||
| 07e597de40 | |||
| 10ae84869a | |||
| dcd2449b1a | |||
| a3d214f8d7 | |||
| 37be162435 | |||
| 5566292cc8 | |||
| 65e2144b3d | |||
| a26d195404 | |||
| 21388a0a40 | |||
| acecd80afa | |||
| 69fc75c0b2 | |||
| 9cdb267ba5 | |||
| 56b050422d | |||
| 546baf9934 | |||
| e6344a8c24 | |||
| 873d10df42 | |||
| 02a36afc38 | |||
| 563d81e08e | |||
| 5f28614760 | |||
| b6b0727e28 | |||
| e9a601c1fe | |||
| 308aa70718 | |||
| 6be034392f | |||
| 3b1dbcd42d | |||
| 2ac9fe08f7 | |||
| 24c722454b | |||
| 0a216b28f3 | |||
| 9c48cd849b | |||
| 0843d9af58 | |||
| b0d5c4c058 | |||
| d18c96d6a1 | |||
| dced409ac4 | |||
| d6a2f1d670 | |||
| 58fd00a3df | |||
| b923604602 | |||
| 8643d60b88 | |||
| 8feeffb29b | |||
| e64146914a | |||
| 6084708cc2 | |||
| 8ed0f8981f | |||
| 41a1a3eed1 | |||
| 10ba348a3a | |||
| db67bb106a | |||
| b828b4754b | |||
| 9383b54d50 | |||
| 181da07dd9 | |||
| 419464a8c2 | |||
| 2e229d3bb6 | |||
| 3c92bdb1ad | |||
| e0f159085e | |||
| 402148dbc4 | |||
| 70696e364b | |||
| 1e10492e55 | |||
| 9e6914d641 | |||
| 0c4fb6afd2 | |||
| edab97997a | |||
| 2515b89e2b | |||
| 60f0f19d76 | |||
| 322232e514 | |||
| 54466a3ea8 | |||
| 7da060c149 | |||
| 088f48d60f | |||
| 2b426c1d91 | |||
| bc168bbae4 | |||
| 124a6da743 | |||
| 739c02bd6d | |||
| 5d13145995 | |||
| c28ff3d5a5 | |||
| 6ddd5abd7b | |||
| bf6d0485ce | |||
| 3d486ec1e9 | |||
| a6d1245a54 | |||
| de7e1937f6 | |||
| b465e12747 | |||
| 948401a450 | |||
| bb8f181ef1 | |||
| bf90b59935 | |||
| 18698d6929 | |||
| 80c4225286 | |||
| d02f3f6809 | |||
| da85aed5aa | |||
| 7f2d33458a | |||
| 3e9a0a7628 | |||
| 26c115a4fb | |||
| db643e9166 | |||
| ba100642e3 | |||
| cc52839f19 | |||
| e79b149985 | |||
| 5e21c8c03c |
@@ -0,0 +1,29 @@
|
||||
description="Injects context of all relevant cli files"
|
||||
prompt = """
|
||||
The following output contains the complete
|
||||
source code of packages/core.
|
||||
**Pay extremely close attention to these files.** They define the project's
|
||||
core architecture, component patterns, and testing standards.
|
||||
|
||||
The source code contains the content of absolutely every source code file in
|
||||
packages/core.
|
||||
You should very rarely need to read any other files from packages/core to resolve
|
||||
prompts.
|
||||
|
||||
!{find packages/core \\( -path packages/cli/dist -o -path packages/core/dist -o -name node_modules \\) -prune -o -type f \\( -name "*.ts" -o -name "*.tsx" \\) ! -name "*.test.ts" ! -name "*.test.tsx" ! -name "*.d.ts" -exec echo "--- {} ---" \\; -exec cat {} \\;}
|
||||
|
||||
In addition to the code context, you MUST strictly adhere to the following frontend-specific development guidelines when writing code in packages/core.
|
||||
|
||||
## Configuration & Settings
|
||||
* **Settings vs Args**: Use settings for user-configurable options; do not add new CLI arguments.
|
||||
* **Schema**: Add new settings to `packages/cli/src/config/settingsSchema.ts`.
|
||||
* **Documentation**:
|
||||
* If `showInDialog: true`, document in `docs/get-started/configuration.md`.
|
||||
* Ensure `requiresRestart` is correctly set.
|
||||
|
||||
## General
|
||||
* **Logging**: Use `debugLogger` for errors. NEVER leave `console.log/warn/error` in the code.
|
||||
* **TypeScript**: Avoid the non-null assertion operator (`!`).
|
||||
|
||||
{{args}}.
|
||||
"""
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- 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 }}'
|
||||
secret: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
|
||||
lint:
|
||||
name: 'Lint'
|
||||
|
||||
@@ -39,7 +39,6 @@ jobs:
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
|
||||
contains(github.event.issue.labels.*.name, 'status/need-triage') &&
|
||||
(github.event_name != 'issue_comment' || (
|
||||
contains(github.event.comment.body, '@gemini-cli /triage') &&
|
||||
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR')
|
||||
@@ -75,11 +74,6 @@ jobs:
|
||||
ISSUE_NUMBER_INPUT: '${{ github.event.inputs.issue_number }}'
|
||||
LABELS: '${{ steps.get_issue_data.outputs.labels }}'
|
||||
run: |
|
||||
if ! echo "${LABELS}" | grep -q 'status/need-triage'; then
|
||||
echo "Issue #${ISSUE_NUMBER_INPUT} does not have the 'status/need-triage' label. Stopping workflow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if echo "${LABELS}" | grep -q 'area/'; then
|
||||
echo "Issue #${ISSUE_NUMBER_INPUT} already has 'area/' label. Stopping workflow."
|
||||
exit 1
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: 'Run Gemini PR size and complexity labeller'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # Use the specific commit SHA
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
with:
|
||||
|
||||
@@ -45,11 +45,11 @@ jobs:
|
||||
|
||||
echo '🔍 Finding issues without labels...'
|
||||
NO_LABEL_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue no:label' --json number,title,body)"
|
||||
--search 'is:open is:issue no:label' --limit 10 --json number,title,body)"
|
||||
|
||||
echo '🏷️ Finding issues that need triage...'
|
||||
NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search "is:open is:issue label:\"status/need-triage\"" --limit 1000 --json number,title,body)"
|
||||
--search "is:open is:issue label:\"status/need-triage\"" --limit 10 --json number,title,body)"
|
||||
|
||||
echo '🔄 Merging and deduplicating issues...'
|
||||
ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: 'Label Child Issues for Project Rollup'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: ['opened', 'edited', 'reopened']
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Check for Parent Workstream and Apply Label'
|
||||
uses: 'actions/github-script@v7'
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const labelToAdd = 'workstream-rollup';
|
||||
|
||||
// --- Define the FULL URLs of the allowed parent workstreams ---
|
||||
const allowedParentUrls = [
|
||||
'https://api.github.com/repos/google-gemini/gemini-cli/issues/15374',
|
||||
'https://api.github.com/repos/google-gemini/gemini-cli/issues/15456',
|
||||
'https://api.github.com/repos/google-gemini/gemini-cli/issues/15324'
|
||||
];
|
||||
|
||||
// Check if the issue has a parent_issue_url and if it's in our allowed list.
|
||||
if (issue && issue.parent_issue_url && allowedParentUrls.includes(issue.parent_issue_url)) {
|
||||
console.log(`SUCCESS: Issue #${issue.number} is a child of a target workstream (${issue.parent_issue_url}). Adding label.`);
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [labelToAdd]
|
||||
});
|
||||
} else if (issue && issue.parent_issue_url) {
|
||||
console.log(`FAILURE: Issue #${issue.number} has a parent, but it's not a target workstream. Parent URL: ${issue.parent_issue_url}`);
|
||||
} else {
|
||||
console.log(`FAILURE: Issue #${issue.number} is not a child of any issue. No action taken.`);
|
||||
}
|
||||
@@ -55,3 +55,4 @@ gha-creds-*.json
|
||||
patch_output.log
|
||||
|
||||
.genkit
|
||||
.gemini-clipboard/
|
||||
|
||||
@@ -391,6 +391,15 @@ When working in the `/docs` directory, follow the guidelines in this section:
|
||||
Only write high-value comments if at all. Avoid talking to the user through
|
||||
comments.
|
||||
|
||||
## Logging and Error Handling
|
||||
|
||||
- **Avoid Console Statements:** Do not use `console.log`, `console.error`, or
|
||||
similar methods directly.
|
||||
- **Non-User-Facing Logs:** For developer-facing debug messages, use
|
||||
`debugLogger` (from `@google/gemini-cli-core`).
|
||||
- **User-Facing Feedback:** To surface errors or warnings to the user, use
|
||||
`coreEvents.emitFeedback` (from `@google/gemini-cli-core`).
|
||||
|
||||
## General requirements
|
||||
|
||||
- If there is something you do not understand or is ambiguous, seek confirmation
|
||||
|
||||
@@ -18,6 +18,41 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.21.0 - 2025-12-15
|
||||
|
||||
- **⚡️⚡️⚡️ Gemini 3 Flash + Gemini CLI:** Better, faster and cheaper than 2.5
|
||||
Pro - and in some scenarios better than 3 Pro! For paid tiers + free tier
|
||||
users who were on the wait list enable **Preview Features** in `/settings.`
|
||||
- For more information:
|
||||
[Gemini 3 Flash is now available in Gemini CLI](https://developers.googleblog.com/gemini-3-flash-is-now-available-in-gemini-cli/).
|
||||
- 🎉 Gemini CLI Extensions:
|
||||
- Rill: Utilize natural language to analyze Rill data, enabling the
|
||||
exploration of metrics and trends without the need for manual queries.
|
||||
`gemini extensions install https://github.com/rilldata/rill-gemini-extension`
|
||||
- Browserbase: Interact with web pages, take screenshots, extract information,
|
||||
and perform automated actions with atomic precision.
|
||||
`gemini extensions install https://github.com/browserbase/mcp-server-browserbase`
|
||||
- Quota Visibility: The `/stats` command now displays quota information for all
|
||||
available models, including those not used in the current session. (@sehoon38)
|
||||
- Fuzzy Setting Search: Users can now quickly find settings using fuzzy search
|
||||
within the settings dialog. (@sehoon38)
|
||||
- MCP Resource Support: Users can now discover, view, and search through
|
||||
resources using the @ command. (@MrLesk)
|
||||
- Auto-execute Simple Slash Commands: Simple slash commands are now executed
|
||||
immediately on enter. (@jackwotherspoon)
|
||||
|
||||
## Announcements: v0.20.0 - 2025-12-01
|
||||
|
||||
- **Multi-file Drag & Drop:** Users can now drag and drop multiple files into
|
||||
the terminal, and the CLI will automatically prefix each valid path with `@`.
|
||||
([pr](https://github.com/google-gemini/gemini-cli/pull/14832) by
|
||||
[@jackwotherspoon](https://github.com/jackwotherspoon))
|
||||
- **Persistent "Always Allow" Policies:** Users can now save "Always Allow"
|
||||
decisions for tool executions, with granular control over specific shell
|
||||
commands and multi-cloud platform tools.
|
||||
([pr](https://github.com/google-gemini/gemini-cli/pull/14737) by
|
||||
[@allenhutchison](https://github.com/allenhutchison))
|
||||
|
||||
## Announcements: v0.19.0 - 2025-11-24
|
||||
|
||||
- 🎉 **New extensions:**
|
||||
@@ -54,8 +89,7 @@ on GitHub.
|
||||
- **Experimental permission improvements:** We are now experimenting with a new
|
||||
policy engine in Gemini CLI. This allows users and administrators to create
|
||||
fine-grained policy for tool calls. Currently behind a flag. See
|
||||
[https://geminicli.com/docs/core/policy-engine/](../core/policy-engine.md) for
|
||||
more information.
|
||||
[policy engine documentation](../core/policy-engine.md) for more information.
|
||||
- Blog:
|
||||
[https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/](https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/)
|
||||
- **Gemini 3 support for paid:** Gemini 3 support has been rolled out to all API
|
||||
|
||||
+209
-138
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.19.0 - v0.19.4
|
||||
# Latest stable release: v0.21.0 - v0.21.1
|
||||
|
||||
Released: December 1, 2025
|
||||
Released: December 16, 2025
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,144 +11,215 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Zed integration:** Users can now leverage Gemini 3 within the Zed
|
||||
integration after enabling "Preview Features" in their CLI’s `/settings`.
|
||||
- **Interactive shell:**
|
||||
- **Click-to-Focus:** Go to `/settings` and enable **Use Alternate Buffer** to
|
||||
click within the embedded shell output to focus it for input.
|
||||
- **Loading phrase:** Clearly indicates when the interactive shell is awaiting
|
||||
user input. ([vid](https://imgur.com/a/kjK8bUK),
|
||||
[pr](https://github.com/google-gemini/gemini-cli/pull/12535) by
|
||||
[@jackwotherspoon](https://github.com/jackwotherspoon))
|
||||
- **⚡️⚡️⚡️ Gemini 3 Flash + Gemini CLI:** If you are a paid user, you can now
|
||||
enable Gemini 3 Pro and Gemini 3 Flash. Go to `/settings` and set **Preview
|
||||
Features** to `true` to enable Gemini 3. For more information:
|
||||
[Gemini 3 Flash is now available in Gemini CLI](https://developers.googleblog.com/gemini-3-flash-is-now-available-in-gemini-cli/).
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Use lenient MCP output schema validator by @cornmander in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13521
|
||||
- Update persistence state to track counts of messages instead of times banner
|
||||
has been displayed by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13428
|
||||
- update docs for http proxy by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13538
|
||||
- move stdio by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13528
|
||||
- chore(release): bump version to 0.19.0-nightly.20251120.8e531dc02 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13540
|
||||
- Skip pre-commit hooks for shadow repo (#13331) by @vishvananda in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13488
|
||||
- fix(ui): Correct mouse click cursor positioning for wide characters by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/13537
|
||||
- fix(core): correct bash @P prompt transformation detection by @pyrytakala in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13544
|
||||
- Optimize and improve test coverage for cli/src/config by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13485
|
||||
- Improve code coverage for cli/src/ui/privacy package by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13493
|
||||
- docs: fix typos in source code and documentation by @fancive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13577
|
||||
- Improved code coverage for cli/src/zed-integration by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13570
|
||||
- feat(ui): build interactive session browser component by @bl-ue in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13351
|
||||
- Fix multiple bugs with auth flow including using the implemented but unused
|
||||
restart support. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13565
|
||||
- feat(core): add modelAvailabilityService for managing and tracking model
|
||||
health by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13426
|
||||
- docs: fix grammar typo "a MCP" to "an MCP" by @noahacgn in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13595
|
||||
- feat: custom loading phrase when interactive shell requires input by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/12535
|
||||
- docs: Update uninstall command to reflect multiple extension support by
|
||||
@JayadityaGit in https://github.com/google-gemini/gemini-cli/pull/13582
|
||||
- bug(core): Ensure we use thinking budget on fallback to 2.5 by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13596
|
||||
- Remove useModelRouter experimental flag by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13593
|
||||
- feat(docs): Ensure multiline JS objects are rendered properly. by @joshualitt
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13535
|
||||
- Fix exp id logging by @owenofbrien in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13430
|
||||
- Moved client id logging into createBasicLogEvent by @owenofbrien in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13607
|
||||
- Restore bracketed paste mode after external editor exit by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13606
|
||||
- feat(core): Add support for custom aliases for model configs. by @joshualitt
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13546
|
||||
- feat(core): Add `BaseLlmClient.generateContent`. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13591
|
||||
- Turn off alternate buffer mode by default. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13623
|
||||
- fix(cli): Prevent stdout/stderr patching for extension commands by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13600
|
||||
- Improve test coverage for cli/src/ui/components by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13598
|
||||
- Update ink version to 6.4.6 by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13631
|
||||
- chore/release: bump version to 0.19.0-nightly.20251122.42c2e1b21 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13637
|
||||
- chore/release: bump version to 0.19.0-nightly.20251123.dadd606c0 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13675
|
||||
- chore/release: bump version to 0.19.0-nightly.20251124.e177314a4 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13713
|
||||
- fix(core): Fix context window overflow warning for PDF files by @kkitase in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13548
|
||||
- feat :rephrasing the extension logging messages to run the explore command
|
||||
when there are no extensions installed by @JayadityaGit in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13740
|
||||
- Improve code coverage for cli package by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13724
|
||||
- Add session subtask in /stats command by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13750
|
||||
- feat(core): Migrate chatCompressionService to model configs. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12863
|
||||
- feat(hooks): Hook Telemetry Infrastructure by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9082
|
||||
- fix: (some minor improvements to configs and getPackageJson return behaviour)
|
||||
by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12510
|
||||
- feat(hooks): Hook Event Handling by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9097
|
||||
- feat(hooks): Hook Agent Lifecycle Integration by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9105
|
||||
- feat(core): Land bool for alternate system prompt. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13764
|
||||
- bug(core): Add default chat compression config. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13766
|
||||
- feat(model-availability): introduce ModelPolicy and PolicyCatalog by
|
||||
@adamfweidman in https://github.com/google-gemini/gemini-cli/pull/13751
|
||||
- feat(hooks): Hook System Orchestration by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9102
|
||||
- feat(config): add isModelAvailabilityServiceEnabled setting by @adamfweidman
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13777
|
||||
- chore/release: bump version to 0.19.0-nightly.20251125.f6d97d448 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13782
|
||||
- chore: remove console.error by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13779
|
||||
- fix: Add $schema property to settings.schema.json by @sacrosanctic in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12763
|
||||
- fix(cli): allow non-GitHub SCP-styled URLs for extension installation by @m0ps
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13800
|
||||
- fix(resume): allow passing a prompt via stdin while resuming using --resume by
|
||||
@bl-ue in https://github.com/google-gemini/gemini-cli/pull/13520
|
||||
- feat(sessions): add /resume slash command to open the session browser by
|
||||
@bl-ue in https://github.com/google-gemini/gemini-cli/pull/13621
|
||||
- docs(sessions): add documentation for chat recording and session management by
|
||||
@bl-ue in https://github.com/google-gemini/gemini-cli/pull/13667
|
||||
- Fix TypeError: "URL.parse is not a function" for Node.js < v22 by @macarronesc
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13698
|
||||
- fallback to flash for TerminalQuota errors by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13791
|
||||
- Update Code Wiki README badge by @PatoBeltran in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13768
|
||||
- Add Databricks auth support and custom header option to gemini cli by
|
||||
@AarushiShah in https://github.com/google-gemini/gemini-cli/pull/11893
|
||||
- Update dependency for modelcontextprotocol/sdk to 1.23.0 by @bbiggs in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13827
|
||||
- fix(patch): cherry-pick 576fda1 to release/v0.19.0-preview.0-pr-14099
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14402
|
||||
- refactor(stdio): always patch stdout and use createWorkingStdio for clean
|
||||
output by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14159
|
||||
- chore(release): bump version to 0.21.0-nightly.20251202.2d935b379 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14409
|
||||
- implement fuzzy search inside settings by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13864
|
||||
- feat: enable message bus integration by default by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14329
|
||||
- docs: Recommend using --debug intead of --verbose for CLI debugging by @bbiggs
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14334
|
||||
- feat: consolidate remote MCP servers to use `url` in config by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/13762
|
||||
- Restrict integration tests tools by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14403
|
||||
- track github repository names in telemetry events by @IamRiddhi in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13670
|
||||
- Allow telemetry exporters to GCP to utilize user's login credentials, if
|
||||
requested by @mboshernitsan in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13778
|
||||
- refactor(editor): use const assertion for editor types with single source of
|
||||
truth by @amsminn in https://github.com/google-gemini/gemini-cli/pull/8604
|
||||
- fix(security): Fix npm audit vulnerabilities in glob and body-parser by
|
||||
@afarber in https://github.com/google-gemini/gemini-cli/pull/14090
|
||||
- Add new enterprise instructions by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/8641
|
||||
- feat(hooks): Hook Session Lifecycle & Compression Integration by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14151
|
||||
- Avoid triggering refreshStatic unless there really is a banner to display. by
|
||||
@jacob314 in https://github.com/google-gemini/gemini-cli/pull/14328
|
||||
- feat(hooks): Hooks Commands Panel, Enable/Disable, and Migrate by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14225
|
||||
- fix: Bundle default policies for npx distribution by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14457
|
||||
- feat(hooks): Hook System Documentation by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14307
|
||||
- Fix tests by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14458
|
||||
- feat: add scheduled workflow to close stale issues by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14404
|
||||
- feat: Support Extension Hooks with Security Warning by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14460
|
||||
- feat: Add enableAgents experimental flag by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14371
|
||||
- docs: fix typo 'socus' to 'focus' in todos.md by @Viktor286 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14374
|
||||
- Markdown export: move the emoji to the end of the line by @mhansen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12278
|
||||
- fix(acp): prevent unnecessary credential cache clearing on re-authent… by
|
||||
@h-michael in https://github.com/google-gemini/gemini-cli/pull/9410
|
||||
- fix(cli): Fix word navigation for CJK characters by @SandyTao520 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14475
|
||||
- Remove example extension by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14376
|
||||
- Add commands for listing and updating per-extension settings by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12664
|
||||
- chore(tests): remove obsolete test for hierarchical memory by @pareshjoshij in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13122
|
||||
- feat(cli): support /copy in remote sessions using OSC52 by @ismellpillows in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13471
|
||||
- Update setting search UX by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14451
|
||||
- Fix(cli): Improve Homebrew update instruction to specify gemini-cli by
|
||||
@DaanVersavel in https://github.com/google-gemini/gemini-cli/pull/14502
|
||||
- do not toggle the setting item when entering space by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14489
|
||||
- fix: improve retry logic for fetch errors and network codes by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14439
|
||||
- remove unused isSearching field by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14509
|
||||
- feat(mcp): add `--type` alias for `--transport` flag in gemini mcp add by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14503
|
||||
- feat(cli): Move key restore logic to core by @cocosheng-g in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13013
|
||||
- feat: add auto-execute on Enter behavior to argumentless MCP prompts by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14510
|
||||
- fix(shell): cursor visibility when using interactive mode by @aswinashok44 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14095
|
||||
- Adding session id as part of json o/p by @MJjainam in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14504
|
||||
- fix(extensions): resolve GitHub API 415 error for source tarballs by
|
||||
@jpoehnelt in https://github.com/google-gemini/gemini-cli/pull/13319
|
||||
- fix(client): Correctly latch hasFailedCompressionAttempt flag by @pareshjoshij
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13002
|
||||
- Disable flaky extension reloading test on linux by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14528
|
||||
- Add support for MCP dynamic tool update by `notifications/tools/list_changed`
|
||||
by @Adib234 in https://github.com/google-gemini/gemini-cli/pull/14375
|
||||
- Fix privacy screen for legacy tier users by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14522
|
||||
- feat: Exclude maintainer labeled issues from stale issue closer by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14532
|
||||
- Grant chained workflows proper permission. by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14534
|
||||
- Make trigger_e2e manually fireable. by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14547
|
||||
- Write e2e status to local repo not forked repo by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14549
|
||||
- Fixes [API Error: Cannot read properties of undefined (reading 'error')] by
|
||||
@silviojr in https://github.com/google-gemini/gemini-cli/pull/14553
|
||||
- Trigger chained e2e tests on all pull requests by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14551
|
||||
- Fix bug in the shellExecutionService resulting in both truncation and 3X bloat
|
||||
by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14545
|
||||
- Fix issue where we were passing the model content reflecting terminal line
|
||||
wrapping. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14566
|
||||
- chore/release: bump version to 0.21.0-nightly.20251204.3da4fd5f7 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14476
|
||||
- feat(sessions): use 1-line generated session summary to describe sessions by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14467
|
||||
- Use Robot PAT for chained e2e merge queue skipper by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14585
|
||||
- fix(core): improve API response error handling and retry logic by @mattKorwel
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14563
|
||||
- Docs: Model routing clarification by @jkcinouye in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14373
|
||||
- expose previewFeatures flag in a2a by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14550
|
||||
- Fix emoji width in debug console. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14593
|
||||
- Fully detach autoupgrade process by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14595
|
||||
- Docs: Update Gemini 3 on Gemini CLI documentation by @jkcinouye in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14601
|
||||
- Disallow floating promises. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14605
|
||||
- chore/release: bump version to 0.21.0-nightly.20251207.025e450ac by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14662
|
||||
- feat(modelAvailabilityService): integrate model availability service into
|
||||
backend logic by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14470
|
||||
- Add prompt_id propagation in a2a-server task by @koxkox111 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14581
|
||||
- Fix: Prevent freezing in non-interactive Gemini CLI when debug mode is enabled
|
||||
by @parthasaradhie in https://github.com/google-gemini/gemini-cli/pull/14580
|
||||
- fix(audio): improve reading of audio files by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14658
|
||||
- Update automated triage workflow to stop assigning priority labels by
|
||||
@skeshive in https://github.com/google-gemini/gemini-cli/pull/14717
|
||||
- set failed status when chained e2e fails by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14725
|
||||
- feat(github action) Triage and Label Pull Requests by Size and Comple… by
|
||||
@DaanVersavel in https://github.com/google-gemini/gemini-cli/pull/5571
|
||||
- refactor(telemetry): Improve previous PR that allows telemetry to use the CLI
|
||||
auth and add testing by @mboshernitsan in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14589
|
||||
- Always set status in chained_e2e workflow by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14730
|
||||
- feat: Add OTEL log event `gemini_cli.startup_stats` for startup stats. by
|
||||
@kevin-ramdass in https://github.com/google-gemini/gemini-cli/pull/14734
|
||||
- feat: auto-execute on slash command completion functions by @jackwotherspoon
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14584
|
||||
- Docs: Proper release notes by @jkcinouye in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14405
|
||||
- Add support for user-scoped extension settings by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13748
|
||||
- refactor(core): Improve environment variable handling in shell execution by
|
||||
@galz10 in https://github.com/google-gemini/gemini-cli/pull/14742
|
||||
- Remove old E2E Workflows by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14749
|
||||
- fix: handle missing local extension config and skip hooks when disabled by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14744
|
||||
- chore/release: bump version to 0.21.0-nightly.20251209.ec9a8c7a7 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14751
|
||||
- feat: Add support for MCP Resources by @MrLesk in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13178
|
||||
- Always set pending status in E2E tests by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14756
|
||||
- fix(lint): upgrade pip and use public pypi for yamllint by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14746
|
||||
- fix: use Gemini API supported image formats for clipboard by @jackwotherspoon
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14762
|
||||
- feat(a2a): Introduce restore command for a2a server by @cocosheng-g in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13015
|
||||
- allow final:true to be returned on a2a server edit calls. by @DavidAPierce in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14747
|
||||
- (fix) Automated pr labeller by @DaanVersavel in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14788
|
||||
- Update CODEOWNERS by @kklashtorny1 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14830
|
||||
- Docs: Fix errors preventing site rebuild. by @jkcinouye in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14842
|
||||
- chore(deps): bump express from 5.1.0 to 5.2.0 by @dependabot[bot] in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14325
|
||||
- fix(patch): cherry-pick 3f5f030 to release/v0.21.0-preview.0-pr-14843 to patch
|
||||
version v0.21.0-preview.0 and create version 0.21.0-preview.1 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14851
|
||||
- fix(patch): cherry-pick ee6556c to release/v0.21.0-preview.1-pr-14691 to patch
|
||||
version v0.21.0-preview.1 and create version 0.21.0-preview.2 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14908
|
||||
- fix(patch): cherry-pick 54de675 to release/v0.21.0-preview.2-pr-14961 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14968
|
||||
- fix(patch): cherry-pick 12cbe32 to release/v0.21.0-preview.3-pr-15000 to patch
|
||||
version v0.21.0-preview.3 and create version 0.21.0-preview.4 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15003
|
||||
- fix(patch): cherry-pick edbe548 to release/v0.21.0-preview.4-pr-15007 to patch
|
||||
version v0.21.0-preview.4 and create version 0.21.0-preview.5 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15015
|
||||
- fix(patch): cherry-pick 2995af6 to release/v0.21.0-preview.5-pr-15131 to patch
|
||||
version v0.21.0-preview.5 and create version 0.21.0-preview.6 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15153
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.18.4...v0.19.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.20.2...v0.21.0
|
||||
|
||||
+115
-127
@@ -1,6 +1,6 @@
|
||||
# Preview release: Release v0.19.0-preview.0
|
||||
# Preview release: Release v0.22.0-preview.0
|
||||
|
||||
Released: November 25, 2025
|
||||
Released: December 16, 2025
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -11,131 +11,119 @@ To install the preview release:
|
||||
npm install -g @google/gemini-cli@preview
|
||||
```
|
||||
|
||||
## What's changed
|
||||
## What's Changed
|
||||
|
||||
- Use lenient MCP output schema validator by @cornmander in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13521
|
||||
- Update persistence state to track counts of messages instead of times banner
|
||||
has been displayed by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13428
|
||||
- update docs for http proxy by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13538
|
||||
- move stdio by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13528
|
||||
- chore(release): bump version to 0.19.0-nightly.20251120.8e531dc02 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13540
|
||||
- Skip pre-commit hooks for shadow repo (#13331) by @vishvananda in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13488
|
||||
- fix(ui): Correct mouse click cursor positioning for wide characters by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/13537
|
||||
- fix(core): correct bash @P prompt transformation detection by @pyrytakala in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13544
|
||||
- Optimize and improve test coverage for cli/src/config by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13485
|
||||
- Improve code coverage for cli/src/ui/privacy package by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13493
|
||||
- docs: fix typos in source code and documentation by @fancive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13577
|
||||
- Improved code coverage for cli/src/zed-integration by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13570
|
||||
- feat(ui): build interactive session browser component by @bl-ue in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13351
|
||||
- Fix multiple bugs with auth flow including using the implemented but unused
|
||||
restart support. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13565
|
||||
- feat(core): add modelAvailabilityService for managing and tracking model
|
||||
health by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13426
|
||||
- docs: fix grammar typo "a MCP" to "an MCP" by @noahacgn in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13595
|
||||
- feat: custom loading phrase when interactive shell requires input by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/12535
|
||||
- docs: Update uninstall command to reflect multiple extension support by
|
||||
@JayadityaGit in https://github.com/google-gemini/gemini-cli/pull/13582
|
||||
- bug(core): Ensure we use thinking budget on fallback to 2.5 by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13596
|
||||
- Remove useModelRouter experimental flag by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13593
|
||||
- feat(docs): Ensure multiline JS objects are rendered properly. by @joshualitt
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13535
|
||||
- Fix exp id logging by @owenofbrien in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13430
|
||||
- Moved client id logging into createBasicLogEvent by @owenofbrien in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13607
|
||||
- Restore bracketed paste mode after external editor exit by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13606
|
||||
- feat(core): Add support for custom aliases for model configs. by @joshualitt
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13546
|
||||
- feat(core): Add `BaseLlmClient.generateContent`. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13591
|
||||
- Turn off alternate buffer mode by default. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13623
|
||||
- fix(cli): Prevent stdout/stderr patching for extension commands by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13600
|
||||
- Improve test coverage for cli/src/ui/components by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13598
|
||||
- Update ink version to 6.4.6 by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13631
|
||||
- chore/release: bump version to 0.19.0-nightly.20251122.42c2e1b21 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13637
|
||||
- chore/release: bump version to 0.19.0-nightly.20251123.dadd606c0 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13675
|
||||
- chore/release: bump version to 0.19.0-nightly.20251124.e177314a4 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13713
|
||||
- fix(core): Fix context window overflow warning for PDF files by @kkitase in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13548
|
||||
- feat :rephrasing the extension logging messages to run the explore command
|
||||
when there are no extensions installed by @JayadityaGit in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13740
|
||||
- Improve code coverage for cli package by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13724
|
||||
- Add session subtask in /stats command by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13750
|
||||
- feat(core): Migrate chatCompressionService to model configs. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12863
|
||||
- feat(hooks): Hook Telemetry Infrastructure by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9082
|
||||
- fix: (some minor improvements to configs and getPackageJson return behaviour)
|
||||
by @grMLEqomlkkU5Eeinz4brIrOVCUCkJuN in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12510
|
||||
- feat(hooks): Hook Event Handling by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9097
|
||||
- feat(hooks): Hook Agent Lifecycle Integration by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9105
|
||||
- feat(core): Land bool for alternate system prompt. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13764
|
||||
- bug(core): Add default chat compression config. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13766
|
||||
- feat(model-availability): introduce ModelPolicy and PolicyCatalog by
|
||||
@adamfweidman in https://github.com/google-gemini/gemini-cli/pull/13751
|
||||
- feat(hooks): Hook System Orchestration by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9102
|
||||
- feat(config): add isModelAvailabilityServiceEnabled setting by @adamfweidman
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13777
|
||||
- chore/release: bump version to 0.19.0-nightly.20251125.f6d97d448 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13782
|
||||
- chore: remove console.error by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13779
|
||||
- fix: Add $schema property to settings.schema.json by @sacrosanctic in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12763
|
||||
- fix(cli): allow non-GitHub SCP-styled URLs for extension installation by @m0ps
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13800
|
||||
- fix(resume): allow passing a prompt via stdin while resuming using --resume by
|
||||
@bl-ue in https://github.com/google-gemini/gemini-cli/pull/13520
|
||||
- feat(sessions): add /resume slash command to open the session browser by
|
||||
@bl-ue in https://github.com/google-gemini/gemini-cli/pull/13621
|
||||
- docs(sessions): add documentation for chat recording and session management by
|
||||
@bl-ue in https://github.com/google-gemini/gemini-cli/pull/13667
|
||||
- Fix TypeError: "URL.parse is not a function" for Node.js < v22 by @macarronesc
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13698
|
||||
- fallback to flash for TerminalQuota errors by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13791
|
||||
- Update Code Wiki README badge by @PatoBeltran in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13768
|
||||
- Add Databricks auth support and custom header option to gemini cli by
|
||||
@AarushiShah in https://github.com/google-gemini/gemini-cli/pull/11893
|
||||
- Update dependency for modelcontextprotocol/sdk to 1.23.0 by @bbiggs in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13827
|
||||
- feat(ide): fallback to GEMINI_CLI_IDE_AUTH_TOKEN env var by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14843
|
||||
- feat: display quota stats for unused models in /stats by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14764
|
||||
- feat: ensure codebase investigator uses preview model when main agent does by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14412
|
||||
- chore: add closing reason to stale bug workflow by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14861
|
||||
- Send the model and CLI version with the user agent by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14865
|
||||
- refactor(sessions): move session summary generation to startup by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14691
|
||||
- Limit search depth in path corrector by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14869
|
||||
- Fix: Correct typo in code comment by @kuishou68 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14671
|
||||
- feat(core): Plumbing for late resolution of model configs. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14597
|
||||
- feat: attempt more error parsing by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14899
|
||||
- Add missing await. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14910
|
||||
- feat(core): Add support for transcript_path in hooks for git-ai/Gemini
|
||||
extension by @svarlamov in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14663
|
||||
- refactor: implement DelegateToAgentTool with discriminated union by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14769
|
||||
- feat: reset availabilityService on /auth by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14911
|
||||
- chore/release: bump version to 0.21.0-nightly.20251211.8c83e1ea9 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14924
|
||||
- Fix: Correctly detect MCP tool errors by @kevin-ramdass in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14937
|
||||
- increase labeler timeout by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14922
|
||||
- tool(cli): tweak the frontend tool to be aware of more core files from the cli
|
||||
by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14962
|
||||
- feat(cli): polish cached token stats and simplify stats display when quota is
|
||||
present. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14961
|
||||
- feat(settings-validation): add validation for settings schema by @lifefloating
|
||||
in https://github.com/google-gemini/gemini-cli/pull/12929
|
||||
- fix(ide): Update IDE extension to write auth token in env var by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14999
|
||||
- Revert "chore(deps): bump express from 5.1.0 to 5.2.0" by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14998
|
||||
- feat(a2a): Introduce /init command for a2a server by @cocosheng-g in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13419
|
||||
- feat: support multi-file drag and drop of images by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14832
|
||||
- fix(policy): allow codebase_investigator by default in read-only policy by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15000
|
||||
- refactor(ide ext): Update port file name + switch to 1-based index for
|
||||
characters + remove truncation text by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/10501
|
||||
- fix(vscode-ide-companion): correct license generation for workspace
|
||||
dependencies by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15004
|
||||
- fix: temp fix for subagent invocation until subagent delegation is merged to
|
||||
stable by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15007
|
||||
- test: update ide detection tests to make them more robust when run in an ide
|
||||
by @kevin-ramdass in https://github.com/google-gemini/gemini-cli/pull/15008
|
||||
- Remove flex from stats display. See snapshots for diffs. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14983
|
||||
- Add license field into package.json by @jb-perez in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14473
|
||||
- feat: Persistent "Always Allow" policies with granular shell & MCP support by
|
||||
@allenhutchison in https://github.com/google-gemini/gemini-cli/pull/14737
|
||||
- chore/release: bump version to 0.21.0-nightly.20251212.54de67536 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14969
|
||||
- fix(core): commandPrefix word boundary and compound command safety by
|
||||
@allenhutchison in https://github.com/google-gemini/gemini-cli/pull/15006
|
||||
- chore(docs): add 'Maintainers only' label info to CONTRIBUTING.md by @jacob314
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14914
|
||||
- Refresh hooks when refreshing extensions. by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14918
|
||||
- Add clarity to error messages by @gsehgal in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14879
|
||||
- chore : remove a redundant tip by @JayadityaGit in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14947
|
||||
- chore/release: bump version to 0.21.0-nightly.20251213.977248e09 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15029
|
||||
- Disallow redundant typecasts. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15030
|
||||
- fix(auth): prioritize GEMINI_API_KEY env var and skip unnecessary key… by
|
||||
@galz10 in https://github.com/google-gemini/gemini-cli/pull/14745
|
||||
- fix: use zod for safety check result validation by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15026
|
||||
- update(telemetry): add hashed_extension_name to field to extension events by
|
||||
@kiranani in https://github.com/google-gemini/gemini-cli/pull/15025
|
||||
- fix: similar to policy-engine, throw error in case of requiring tool execution
|
||||
confirmation for non-interactive mode by @MayV in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14702
|
||||
- Clean up processes in integration tests by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15102
|
||||
- docs: update policy engine getting started and defaults by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15105
|
||||
- Fix tool output fragmentation by encapsulating content in functionResponse by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/13082
|
||||
- Simplify method signature. by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15114
|
||||
- Show raw input token counts in json output. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15021
|
||||
- fix: Mark A2A requests as interactive by @MayV in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15108
|
||||
- use previewFeatures to determine which pro model to use for A2A by @sehoon38
|
||||
in https://github.com/google-gemini/gemini-cli/pull/15131
|
||||
- refactor(cli): fix settings merging so that settings using the new json format
|
||||
take priority over ones using the old format by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15116
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.18.0-preview.4...v0.19.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.21.0-preview.6...v0.22.0-preview.0
|
||||
|
||||
+475
-9
@@ -12,13 +12,479 @@ on GitHub.
|
||||
|
||||
## Current Releases
|
||||
|
||||
| Release channel | Notes |
|
||||
| :----------------------------------------- | :---------------------------------------------- |
|
||||
| Nightly | Nightly release with the most recent changes. |
|
||||
| [Preview](#release-v0190-preview0-preview) | Experimental features ready for early feedback. |
|
||||
| [Latest](#release-v0190---v0194-latest) | Stable, recommended for general use. |
|
||||
| Release channel | Notes |
|
||||
| :------------------------------------------ | :---------------------------------------------- |
|
||||
| Nightly | Nightly release with the most recent changes. |
|
||||
| [Preview](#release-v0220-preview-0-preview) | Experimental features ready for early feedback. |
|
||||
| [Latest](#release-v0210---v0211-latest) | Stable, recommended for general use. |
|
||||
|
||||
## Release v0.19.0 - v0.19.4 (Latest)
|
||||
## Release v0.21.0 - v0.21.1 (Latest)
|
||||
|
||||
### Highlights
|
||||
|
||||
- **⚡️⚡️⚡️ Gemini 3 Flash + Gemini CLI:** If you are a paid user, you can now
|
||||
enable Gemini 3 Pro and Gemini 3 Flash. Go to `/settings` and set **Preview
|
||||
Features** to `true` to enable Gemini 3. For more information:
|
||||
[Gemini 3 Flash is now available in Gemini CLI](https://developers.googleblog.com/gemini-3-flash-is-now-available-in-gemini-cli/).
|
||||
|
||||
### What's Changed
|
||||
|
||||
- refactor(stdio): always patch stdout and use createWorkingStdio for clean
|
||||
output by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14159
|
||||
- chore(release): bump version to 0.21.0-nightly.20251202.2d935b379 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14409
|
||||
- implement fuzzy search inside settings by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13864
|
||||
- feat: enable message bus integration by default by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14329
|
||||
- docs: Recommend using --debug intead of --verbose for CLI debugging by @bbiggs
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14334
|
||||
- feat: consolidate remote MCP servers to use `url` in config by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/13762
|
||||
- Restrict integration tests tools by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14403
|
||||
- track github repository names in telemetry events by @IamRiddhi in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13670
|
||||
- Allow telemetry exporters to GCP to utilize user's login credentials, if
|
||||
requested by @mboshernitsan in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13778
|
||||
- refactor(editor): use const assertion for editor types with single source of
|
||||
truth by @amsminn in https://github.com/google-gemini/gemini-cli/pull/8604
|
||||
- fix(security): Fix npm audit vulnerabilities in glob and body-parser by
|
||||
@afarber in https://github.com/google-gemini/gemini-cli/pull/14090
|
||||
- Add new enterprise instructions by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/8641
|
||||
- feat(hooks): Hook Session Lifecycle & Compression Integration by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14151
|
||||
- Avoid triggering refreshStatic unless there really is a banner to display. by
|
||||
@jacob314 in https://github.com/google-gemini/gemini-cli/pull/14328
|
||||
- feat(hooks): Hooks Commands Panel, Enable/Disable, and Migrate by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14225
|
||||
- fix: Bundle default policies for npx distribution by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14457
|
||||
- feat(hooks): Hook System Documentation by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14307
|
||||
- Fix tests by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14458
|
||||
- feat: add scheduled workflow to close stale issues by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14404
|
||||
- feat: Support Extension Hooks with Security Warning by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14460
|
||||
- feat: Add enableAgents experimental flag by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14371
|
||||
- docs: fix typo 'socus' to 'focus' in todos.md by @Viktor286 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14374
|
||||
- Markdown export: move the emoji to the end of the line by @mhansen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12278
|
||||
- fix(acp): prevent unnecessary credential cache clearing on re-authent… by
|
||||
@h-michael in https://github.com/google-gemini/gemini-cli/pull/9410
|
||||
- fix(cli): Fix word navigation for CJK characters by @SandyTao520 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14475
|
||||
- Remove example extension by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14376
|
||||
- Add commands for listing and updating per-extension settings by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12664
|
||||
- chore(tests): remove obsolete test for hierarchical memory by @pareshjoshij in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13122
|
||||
- feat(cli): support /copy in remote sessions using OSC52 by @ismellpillows in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13471
|
||||
- Update setting search UX by @Adib234 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14451
|
||||
- Fix(cli): Improve Homebrew update instruction to specify gemini-cli by
|
||||
@DaanVersavel in https://github.com/google-gemini/gemini-cli/pull/14502
|
||||
- do not toggle the setting item when entering space by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14489
|
||||
- fix: improve retry logic for fetch errors and network codes by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14439
|
||||
- remove unused isSearching field by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14509
|
||||
- feat(mcp): add `--type` alias for `--transport` flag in gemini mcp add by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14503
|
||||
- feat(cli): Move key restore logic to core by @cocosheng-g in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13013
|
||||
- feat: add auto-execute on Enter behavior to argumentless MCP prompts by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14510
|
||||
- fix(shell): cursor visibility when using interactive mode by @aswinashok44 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14095
|
||||
- Adding session id as part of json o/p by @MJjainam in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14504
|
||||
- fix(extensions): resolve GitHub API 415 error for source tarballs by
|
||||
@jpoehnelt in https://github.com/google-gemini/gemini-cli/pull/13319
|
||||
- fix(client): Correctly latch hasFailedCompressionAttempt flag by @pareshjoshij
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13002
|
||||
- Disable flaky extension reloading test on linux by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14528
|
||||
- Add support for MCP dynamic tool update by `notifications/tools/list_changed`
|
||||
by @Adib234 in https://github.com/google-gemini/gemini-cli/pull/14375
|
||||
- Fix privacy screen for legacy tier users by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14522
|
||||
- feat: Exclude maintainer labeled issues from stale issue closer by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14532
|
||||
- Grant chained workflows proper permission. by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14534
|
||||
- Make trigger_e2e manually fireable. by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14547
|
||||
- Write e2e status to local repo not forked repo by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14549
|
||||
- Fixes [API Error: Cannot read properties of undefined (reading 'error')] by
|
||||
@silviojr in https://github.com/google-gemini/gemini-cli/pull/14553
|
||||
- Trigger chained e2e tests on all pull requests by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14551
|
||||
- Fix bug in the shellExecutionService resulting in both truncation and 3X bloat
|
||||
by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14545
|
||||
- Fix issue where we were passing the model content reflecting terminal line
|
||||
wrapping. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14566
|
||||
- chore/release: bump version to 0.21.0-nightly.20251204.3da4fd5f7 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14476
|
||||
- feat(sessions): use 1-line generated session summary to describe sessions by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14467
|
||||
- Use Robot PAT for chained e2e merge queue skipper by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14585
|
||||
- fix(core): improve API response error handling and retry logic by @mattKorwel
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14563
|
||||
- Docs: Model routing clarification by @jkcinouye in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14373
|
||||
- expose previewFeatures flag in a2a by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14550
|
||||
- Fix emoji width in debug console. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14593
|
||||
- Fully detach autoupgrade process by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14595
|
||||
- Docs: Update Gemini 3 on Gemini CLI documentation by @jkcinouye in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14601
|
||||
- Disallow floating promises. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14605
|
||||
- chore/release: bump version to 0.21.0-nightly.20251207.025e450ac by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14662
|
||||
- feat(modelAvailabilityService): integrate model availability service into
|
||||
backend logic by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14470
|
||||
- Add prompt_id propagation in a2a-server task by @koxkox111 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14581
|
||||
- Fix: Prevent freezing in non-interactive Gemini CLI when debug mode is enabled
|
||||
by @parthasaradhie in https://github.com/google-gemini/gemini-cli/pull/14580
|
||||
- fix(audio): improve reading of audio files by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14658
|
||||
- Update automated triage workflow to stop assigning priority labels by
|
||||
@skeshive in https://github.com/google-gemini/gemini-cli/pull/14717
|
||||
- set failed status when chained e2e fails by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14725
|
||||
- feat(github action) Triage and Label Pull Requests by Size and Comple… by
|
||||
@DaanVersavel in https://github.com/google-gemini/gemini-cli/pull/5571
|
||||
- refactor(telemetry): Improve previous PR that allows telemetry to use the CLI
|
||||
auth and add testing by @mboshernitsan in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14589
|
||||
- Always set status in chained_e2e workflow by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14730
|
||||
- feat: Add OTEL log event `gemini_cli.startup_stats` for startup stats. by
|
||||
@kevin-ramdass in https://github.com/google-gemini/gemini-cli/pull/14734
|
||||
- feat: auto-execute on slash command completion functions by @jackwotherspoon
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14584
|
||||
- Docs: Proper release notes by @jkcinouye in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14405
|
||||
- Add support for user-scoped extension settings by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13748
|
||||
- refactor(core): Improve environment variable handling in shell execution by
|
||||
@galz10 in https://github.com/google-gemini/gemini-cli/pull/14742
|
||||
- Remove old E2E Workflows by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14749
|
||||
- fix: handle missing local extension config and skip hooks when disabled by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14744
|
||||
- chore/release: bump version to 0.21.0-nightly.20251209.ec9a8c7a7 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14751
|
||||
- feat: Add support for MCP Resources by @MrLesk in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13178
|
||||
- Always set pending status in E2E tests by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14756
|
||||
- fix(lint): upgrade pip and use public pypi for yamllint by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14746
|
||||
- fix: use Gemini API supported image formats for clipboard by @jackwotherspoon
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14762
|
||||
- feat(a2a): Introduce restore command for a2a server by @cocosheng-g in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13015
|
||||
- allow final:true to be returned on a2a server edit calls. by @DavidAPierce in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14747
|
||||
- (fix) Automated pr labeller by @DaanVersavel in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14788
|
||||
- Update CODEOWNERS by @kklashtorny1 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14830
|
||||
- Docs: Fix errors preventing site rebuild. by @jkcinouye in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14842
|
||||
- chore(deps): bump express from 5.1.0 to 5.2.0 by @dependabot[bot] in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14325
|
||||
- fix(patch): cherry-pick 3f5f030 to release/v0.21.0-preview.0-pr-14843 to patch
|
||||
version v0.21.0-preview.0 and create version 0.21.0-preview.1 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14851
|
||||
- fix(patch): cherry-pick ee6556c to release/v0.21.0-preview.1-pr-14691 to patch
|
||||
version v0.21.0-preview.1 and create version 0.21.0-preview.2 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14908
|
||||
- fix(patch): cherry-pick 54de675 to release/v0.21.0-preview.2-pr-14961 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14968
|
||||
- fix(patch): cherry-pick 12cbe32 to release/v0.21.0-preview.3-pr-15000 to patch
|
||||
version v0.21.0-preview.3 and create version 0.21.0-preview.4 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15003
|
||||
- fix(patch): cherry-pick edbe548 to release/v0.21.0-preview.4-pr-15007 to patch
|
||||
version v0.21.0-preview.4 and create version 0.21.0-preview.5 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15015
|
||||
- fix(patch): cherry-pick 2995af6 to release/v0.21.0-preview.5-pr-15131 to patch
|
||||
version v0.21.0-preview.5 and create version 0.21.0-preview.6 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15153
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.20.2...v0.21.0
|
||||
|
||||
## Release v0.22.0-preview-0 (Preview)
|
||||
|
||||
### What's Changed
|
||||
|
||||
- feat(ide): fallback to GEMINI_CLI_IDE_AUTH_TOKEN env var by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14843
|
||||
- feat: display quota stats for unused models in /stats by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14764
|
||||
- feat: ensure codebase investigator uses preview model when main agent does by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14412
|
||||
- chore: add closing reason to stale bug workflow by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14861
|
||||
- Send the model and CLI version with the user agent by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14865
|
||||
- refactor(sessions): move session summary generation to startup by
|
||||
@jackwotherspoon in https://github.com/google-gemini/gemini-cli/pull/14691
|
||||
- Limit search depth in path corrector by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14869
|
||||
- Fix: Correct typo in code comment by @kuishou68 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14671
|
||||
- feat(core): Plumbing for late resolution of model configs. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14597
|
||||
- feat: attempt more error parsing by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14899
|
||||
- Add missing await. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14910
|
||||
- feat(core): Add support for transcript_path in hooks for git-ai/Gemini
|
||||
extension by @svarlamov in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14663
|
||||
- refactor: implement DelegateToAgentTool with discriminated union by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/14769
|
||||
- feat: reset availabilityService on /auth by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14911
|
||||
- chore/release: bump version to 0.21.0-nightly.20251211.8c83e1ea9 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14924
|
||||
- Fix: Correctly detect MCP tool errors by @kevin-ramdass in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14937
|
||||
- increase labeler timeout by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14922
|
||||
- tool(cli): tweak the frontend tool to be aware of more core files from the cli
|
||||
by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14962
|
||||
- feat(cli): polish cached token stats and simplify stats display when quota is
|
||||
present. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14961
|
||||
- feat(settings-validation): add validation for settings schema by @lifefloating
|
||||
in https://github.com/google-gemini/gemini-cli/pull/12929
|
||||
- fix(ide): Update IDE extension to write auth token in env var by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14999
|
||||
- Revert "chore(deps): bump express from 5.1.0 to 5.2.0" by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14998
|
||||
- feat(a2a): Introduce /init command for a2a server by @cocosheng-g in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13419
|
||||
- feat: support multi-file drag and drop of images by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14832
|
||||
- fix(policy): allow codebase_investigator by default in read-only policy by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/15000
|
||||
- refactor(ide ext): Update port file name + switch to 1-based index for
|
||||
characters + remove truncation text by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/10501
|
||||
- fix(vscode-ide-companion): correct license generation for workspace
|
||||
dependencies by @skeshive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15004
|
||||
- fix: temp fix for subagent invocation until subagent delegation is merged to
|
||||
stable by @abhipatel12 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15007
|
||||
- test: update ide detection tests to make them more robust when run in an ide
|
||||
by @kevin-ramdass in https://github.com/google-gemini/gemini-cli/pull/15008
|
||||
- Remove flex from stats display. See snapshots for diffs. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14983
|
||||
- Add license field into package.json by @jb-perez in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14473
|
||||
- feat: Persistent "Always Allow" policies with granular shell & MCP support by
|
||||
@allenhutchison in https://github.com/google-gemini/gemini-cli/pull/14737
|
||||
- chore/release: bump version to 0.21.0-nightly.20251212.54de67536 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14969
|
||||
- fix(core): commandPrefix word boundary and compound command safety by
|
||||
@allenhutchison in https://github.com/google-gemini/gemini-cli/pull/15006
|
||||
- chore(docs): add 'Maintainers only' label info to CONTRIBUTING.md by @jacob314
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14914
|
||||
- Refresh hooks when refreshing extensions. by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14918
|
||||
- Add clarity to error messages by @gsehgal in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14879
|
||||
- chore : remove a redundant tip by @JayadityaGit in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14947
|
||||
- chore/release: bump version to 0.21.0-nightly.20251213.977248e09 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/15029
|
||||
- Disallow redundant typecasts. by @gundermanc in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15030
|
||||
- fix(auth): prioritize GEMINI_API_KEY env var and skip unnecessary key… by
|
||||
@galz10 in https://github.com/google-gemini/gemini-cli/pull/14745
|
||||
- fix: use zod for safety check result validation by @allenhutchison in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15026
|
||||
- update(telemetry): add hashed_extension_name to field to extension events by
|
||||
@kiranani in https://github.com/google-gemini/gemini-cli/pull/15025
|
||||
- fix: similar to policy-engine, throw error in case of requiring tool execution
|
||||
confirmation for non-interactive mode by @MayV in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14702
|
||||
- Clean up processes in integration tests by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15102
|
||||
- docs: update policy engine getting started and defaults by @NTaylorMullen in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15105
|
||||
- Fix tool output fragmentation by encapsulating content in functionResponse by
|
||||
@abhipatel12 in https://github.com/google-gemini/gemini-cli/pull/13082
|
||||
- Simplify method signature. by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15114
|
||||
- Show raw input token counts in json output. by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15021
|
||||
- fix: Mark A2A requests as interactive by @MayV in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15108
|
||||
- use previewFeatures to determine which pro model to use for A2A by @sehoon38
|
||||
in https://github.com/google-gemini/gemini-cli/pull/15131
|
||||
- refactor(cli): fix settings merging so that settings using the new json format
|
||||
take priority over ones using the old format by @jacob314 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/15116
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.21.0-preview.6...v0.22.0-preview.0
|
||||
|
||||
## Release v0.20.0 - v0.20.2
|
||||
|
||||
### What's Changed
|
||||
|
||||
- Update error codes when process exiting the gemini cli by @megha1188 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13728
|
||||
- chore(release): bump version to 0.20.0-nightly.20251126.d2a6cff4d by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13835
|
||||
- feat(core): Improve request token calculation accuracy by @SandyTao520 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13824
|
||||
- Changes in system instruction to adapt to gemini 3.0 to ensure that the CLI
|
||||
explains its actions before calling tools by @silviojr in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13810
|
||||
- feat(hooks): Hook Tool Execution Integration by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9108
|
||||
- Add support for MCP server instructions behind config option by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13432
|
||||
- Update System Instructions for interactive vs non-interactive mode. by
|
||||
@aishaneeshah in https://github.com/google-gemini/gemini-cli/pull/12315
|
||||
- Add consent flag to Link command by @kevinjwang1 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13832
|
||||
- feat(mcp): Inject GoogleCredentialProvider headers in McpClient by
|
||||
@sai-sunder-s in https://github.com/google-gemini/gemini-cli/pull/13783
|
||||
- feat(core): implement towards policy-driven model fallback mechanism by
|
||||
@adamfweidman in https://github.com/google-gemini/gemini-cli/pull/13781
|
||||
- feat(core): Add configurable inactivity timeout for shell commands by @galz10
|
||||
in https://github.com/google-gemini/gemini-cli/pull/13531
|
||||
- fix(auth): improve API key authentication flow by @galz10 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13829
|
||||
- feat(hooks): Hook LLM Request/Response Integration by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9110
|
||||
- feat(ui): Show waiting MCP servers in ConfigInitDisplay by @werdnum in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13721
|
||||
- Add usage limit remaining in /stats by @sehoon38 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13843
|
||||
- feat(shell): Standardize pager to 'cat' for shell execution by model by
|
||||
@galz10 in https://github.com/google-gemini/gemini-cli/pull/13878
|
||||
- chore/release: bump version to 0.20.0-nightly.20251127.5bed97064 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/13877
|
||||
- Revert to default LICENSE (Revert #13449) by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13876
|
||||
- update(telemetry): OTel API response event with finish reasons by @kiranani in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13849
|
||||
- feat(hooks): Hooks Comprehensive Integration Testing by @Edilmo in
|
||||
https://github.com/google-gemini/gemini-cli/pull/9112
|
||||
- chore: fix session browser test and skip hook system tests by @jackwotherspoon
|
||||
in https://github.com/google-gemini/gemini-cli/pull/14099
|
||||
- feat(telemetry): Add Semantic logging for to ApiRequestEvents by @kiranani in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13912
|
||||
- test: Add verification for $schema property in settings schema by
|
||||
@maryamariyan in https://github.com/google-gemini/gemini-cli/pull/13497
|
||||
- Fixes `/clear` command to preserve input history for up-arrow navigation while
|
||||
still clearing the context window and screen by @korade-krushna in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14182
|
||||
- fix(core): handle EPIPE error in hook runner when writing to stdin by
|
||||
@SandyTao520 in https://github.com/google-gemini/gemini-cli/pull/14231
|
||||
- fix: Exclude web-fetch tool from executing in default non-interactive mode to
|
||||
avoid CLI hang. by @MayV in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14244
|
||||
- Always use MCP server instructions by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14297
|
||||
- feat: auto-execute simple slash commands on Enter by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13985
|
||||
- chore/release: bump version to 0.20.0-nightly.20251201.2fe609cb6 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14304
|
||||
- feat: Add startup profiler to measure and record application initialization
|
||||
phases. by @kevin-ramdass in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13638
|
||||
- bug(core): Avoid stateful tool use in `executor`. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14305
|
||||
- feat(themes): add built-in holiday theme 🎁 by @jackwotherspoon in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14301
|
||||
- Updated ToC on docs intro; updated title casing to match Google style by
|
||||
@pcoet in https://github.com/google-gemini/gemini-cli/pull/13717
|
||||
- feat(a2a): Urgent fix - Process modelInfo agent message by @cocosheng-g in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14315
|
||||
- feat(core): enhance availability routing with wrapped fallback and
|
||||
single-model policies by @adamfweidman in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13874
|
||||
- chore(logging): log the problematic event for #12122 by @briandealwis in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14092
|
||||
- fix: remove invalid type key in bug_report.yml by @fancive in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13576
|
||||
- update screenshot by @Transient-Onlooker in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13976
|
||||
- docs: Fix grammar error in Release Cadence (Nightly section) by @JuanCS-Dev in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13866
|
||||
- fix(async): prevent missed async errors from bypassing catch handlers by
|
||||
@amsminn in https://github.com/google-gemini/gemini-cli/pull/13714
|
||||
- fix(zed-integration): remove extra field from acp auth request by
|
||||
@marcocondrache in https://github.com/google-gemini/gemini-cli/pull/13646
|
||||
- feat(cli): Documentation for model configs. by @joshualitt in
|
||||
https://github.com/google-gemini/gemini-cli/pull/12967
|
||||
- fix(ui): misaligned markdown table rendering by @dumbbellcode in
|
||||
https://github.com/google-gemini/gemini-cli/pull/8336
|
||||
- docs: Update 4 files by @g-samroberts in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13628
|
||||
- fix: Conditionally add set -eEuo pipefail in setup-github command by @Smetalo
|
||||
in https://github.com/google-gemini/gemini-cli/pull/8550
|
||||
- fix(cli): fix issue updating a component while rendering a different component
|
||||
by @jacob314 in https://github.com/google-gemini/gemini-cli/pull/14319
|
||||
- Increase flakey test timeout by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14377
|
||||
- Remove references to deleted kind/bug label by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14383
|
||||
- Don't fail test if we can't cleanup by @scidomino in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14389
|
||||
- feat(core): Implement JIT context manager and setting by @SandyTao520 in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14324
|
||||
- Use polling for extensions-reload integration test by @chrstnb in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14391
|
||||
- Add docs directive to GEMINI.md by @g-samroberts in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14327
|
||||
- Hide sessions that don't have user messages by @bl-ue in
|
||||
https://github.com/google-gemini/gemini-cli/pull/13994
|
||||
- chore(ci): mark GitHub release as pre-release if not on "latest" npm channel
|
||||
by @ljxfstorm in https://github.com/google-gemini/gemini-cli/pull/7386
|
||||
- fix(patch): cherry-pick d284fa6 to release/v0.20.0-preview.0-pr-14545
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
https://github.com/google-gemini/gemini-cli/pull/14559
|
||||
- fix(patch): cherry-pick 828afe1 to release/v0.20.0-preview.1-pr-14159 to patch
|
||||
version v0.20.0-preview.1 and create version 0.20.0-preview.2 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14733
|
||||
- fix(patch): cherry-pick 171103a to release/v0.20.0-preview.2-pr-14742 to patch
|
||||
version v0.20.0-preview.2 and create version 0.20.0-preview.5 by
|
||||
@gemini-cli-robot in https://github.com/google-gemini/gemini-cli/pull/14752
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.19.4...v0.20.0
|
||||
|
||||
## Release v0.19.0 - v0.19.4
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -26,10 +492,10 @@ on GitHub.
|
||||
integration after enabling "Preview Features" in their CLI’s `/settings`.
|
||||
- **Interactive shell:**
|
||||
- **Click-to-Focus:** Go to `/settings` and enable **Use Alternate Buffer**
|
||||
WhenUse Alternate Buffer" setting is enabled users can click within the
|
||||
When "Use Alternate Buffer" setting is enabled users can click within the
|
||||
embedded shell output to focus it for input.
|
||||
- **Loading phrase:** Clearly indicates when the interactive shell is awaiting
|
||||
user input. ([vid](https://imgur.com/a/kjK8bUK),
|
||||
user input. ([vid](https://imgur.com/a/kjK8bUK)
|
||||
[pr](https://github.com/google-gemini/gemini-cli/pull/12535) by
|
||||
[@jackwotherspoon](https://github.com/jackwotherspoon))
|
||||
|
||||
@@ -165,7 +631,7 @@ on GitHub.
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.18.4...v0.19.0
|
||||
|
||||
## Release v0.19.0-preview.0 (Preview)
|
||||
## Release v0.19.0-preview.0
|
||||
|
||||
### What's Changed
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
|
||||
files and directories from being accessed by tools.
|
||||
- **[Context files (GEMINI.md)](./gemini-md.md):** Provide persistent,
|
||||
hierarchical context to the model.
|
||||
- **[System prompt override](./system-prompt.md):** Replace the built‑in system
|
||||
instructions using `GEMINI_SYSTEM_MD`.
|
||||
|
||||
## Non-interactive mode
|
||||
|
||||
|
||||
@@ -84,10 +84,10 @@ available combinations.
|
||||
|
||||
#### External Tools
|
||||
|
||||
| Action | Keys |
|
||||
| ---------------------------------------------- | ---------- |
|
||||
| Open the current prompt in an external editor. | `Ctrl + X` |
|
||||
| Paste from the clipboard. | `Ctrl + V` |
|
||||
| Action | Keys |
|
||||
| ---------------------------------------------- | ------------------------- |
|
||||
| Open the current prompt in an external editor. | `Ctrl + X` |
|
||||
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V` |
|
||||
|
||||
#### App Controls
|
||||
|
||||
|
||||
+11
-15
@@ -6,24 +6,20 @@ and provides resilience when the primary model is unavailable.
|
||||
|
||||
## How it works
|
||||
|
||||
Model routing is not based on prompt complexity, but is a fallback mechanism.
|
||||
Here's how it works:
|
||||
Model routing is managed by the `ModelAvailabilityService`, which monitors model
|
||||
health and automatically routes requests to available models based on defined
|
||||
policies.
|
||||
|
||||
1. **Model failure:** If the currently selected model fails to respond (for
|
||||
example, due to a server error or other issue), the CLI will initiate the
|
||||
fallback process.
|
||||
1. **Model failure:** If the currently selected model fails (e.g., due to quota
|
||||
or server errors), the CLI will iniate the fallback process.
|
||||
|
||||
2. **User consent:** The CLI will prompt you to ask if you want to switch to
|
||||
the fallback model. This is handled by the `fallbackModelHandler`.
|
||||
2. **User consent:** Depending on the failure and the model's policy, the CLI
|
||||
may prompt you to switch to a fallback model (by default always prompts
|
||||
you).
|
||||
|
||||
3. **Fallback activation:** If you consent, the CLI will activate the fallback
|
||||
mode by calling `config.setFallbackMode(true)`.
|
||||
|
||||
4. **Model switch:** On the next request, the CLI will use the
|
||||
`DEFAULT_GEMINI_FLASH_MODEL` as the fallback model. This is handled by the
|
||||
`resolveModel` function in
|
||||
`packages/cli/src/zed-integration/zedIntegration.ts` which checks if
|
||||
`isInFallbackMode()` is true.
|
||||
3. **Model switch:** If approved, or if the policy allows for silent fallback,
|
||||
the CLI will use an available fallback model for the current turn or the
|
||||
remainder of the session.
|
||||
|
||||
### Model selection precedence
|
||||
|
||||
|
||||
+30
-30
@@ -1,12 +1,13 @@
|
||||
# Gemini CLI model selection (`/model` command)
|
||||
|
||||
Select your Gemini CLI model. The `/model` command opens a dialog where you can
|
||||
configure the model used by Gemini CLI, giving you more control over your
|
||||
results.
|
||||
Select your Gemini CLI model. The `/model` command lets you configure the model
|
||||
used by Gemini CLI, giving you more control over your results. Use **Pro**
|
||||
models for complex tasks and reasoning, **Flash** models for high speed results,
|
||||
or the (recommended) **Auto** setting to choose the best model for your tasks.
|
||||
|
||||
**Note:** The `/model` command (and the `--model` flag) does not override the
|
||||
model used by sub-agents. Consequently, even when using the `/model` flag you
|
||||
may see other models used in your model usage reports.
|
||||
> **Note:** The `/model` command (and the `--model` flag) does not override the
|
||||
> model used by sub-agents. Consequently, even when using the `/model` flag you
|
||||
> may see other models used in your model usage reports.
|
||||
|
||||
## How to use the `/model` command
|
||||
|
||||
@@ -16,26 +17,25 @@ Use the following command in Gemini CLI:
|
||||
/model
|
||||
```
|
||||
|
||||
Running this command will open a dialog with your model options:
|
||||
Running this command will open a dialog with your options:
|
||||
|
||||
| Option | Description | Models |
|
||||
| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Auto (recommended) | Let the system choose the best model for your task. | gemini-3-pro-preview (if enabled), gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite |
|
||||
| Pro | For complex tasks that require deep reasoning and creativity. | gemini-3-pro-preview (if enabled), gemini-2.5-pro |
|
||||
| Flash | For tasks that need a balance of speed and reasoning. | gemini-2.5-flash |
|
||||
| Flash-Lite | For simple tasks that need to be done quickly. | gemini-2.5-flash-lite |
|
||||
| Option | Description | Models |
|
||||
| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) |
|
||||
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
|
||||
| Manual | Select a specific model. | Any available model. |
|
||||
|
||||
### Gemini 3 Pro and preview features
|
||||
We recommend selecting one of the above **Auto** options. However, you can
|
||||
select **Manual** to select a specific model from those available.
|
||||
|
||||
Note: Gemini 3 is not currently available on all account types. To learn more
|
||||
about Gemini 3 access, refer to
|
||||
[Gemini 3 Pro on Gemini CLI](../get-started/gemini-3.md).
|
||||
### Gemini 3 and preview features
|
||||
|
||||
To enable Gemini 3 Pro (if available), enable
|
||||
[**Preview features** by using the `settings` command](../cli/settings.md). Once
|
||||
enabled, Gemini CLI will attempt to use Gemini 3 Pro when you select **Auto** or
|
||||
**Pro**. Both **Auto** and **Pro** will try to use Gemini 3 Pro before falling
|
||||
back to Gemini 2.5 Pro.
|
||||
> **Note:** Gemini 3 is not currently available on all account types. To learn
|
||||
> more about Gemini 3 access, refer to
|
||||
> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md).
|
||||
|
||||
To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
|
||||
[**Preview Features** by using the `settings` command](../cli/settings.md).
|
||||
|
||||
You can also use the `--model` flag to specify a particular Gemini model on
|
||||
startup. For more details, refer to the
|
||||
@@ -46,16 +46,16 @@ Gemini CLI.
|
||||
|
||||
## Best practices for model selection
|
||||
|
||||
- **Default to Auto (recommended).** For most users, the _Auto (recommended)_
|
||||
model provides a balance between speed and performance, automatically
|
||||
selecting the correct model based on the complexity of the task. Example:
|
||||
Developing a web application could include a mix of complex tasks (building
|
||||
architecture and scaffolding the project) and simple tasks (generating CSS).
|
||||
- **Default to Auto.** For most users, the _Auto_ option model provides a
|
||||
balance between speed and performance, automatically selecting the correct
|
||||
model based on the complexity of the task. Example: Developing a web
|
||||
application could include a mix of complex tasks (building architecture and
|
||||
scaffolding the project) and simple tasks (generating CSS).
|
||||
|
||||
- **Switch to Pro if you aren't getting the results you want.** If you think you
|
||||
need your model to be a little "smarter," use Pro. Pro will provide you with
|
||||
the highest levels of reasoning and creativity. Example: A complex or
|
||||
multi-stage debugging task.
|
||||
need your model to be a little "smarter," you can manually select Pro. Pro
|
||||
will provide you with the highest levels of reasoning and creativity. Example:
|
||||
A complex or multi-stage debugging task.
|
||||
|
||||
- **Switch to Flash or Flash-Lite if you need faster results.** If you need a
|
||||
simple response quickly, Flash or Flash-Lite is the best option. Example:
|
||||
|
||||
@@ -94,15 +94,16 @@ they appear in the UI.
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `10000` |
|
||||
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `100` |
|
||||
| Enable Message Bus Integration | `tools.enableMessageBusIntegration` | Enable policy-based tool confirmation via message bus integration. | `true` |
|
||||
|
||||
### Security
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ------------------------------ | -------------------------------------------------- | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------------- | ----------------------------------------------- | --------------------------------------------------------- | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
|
||||
| Allowed Environment Variables | `security.environmentVariableRedaction.allowed` | Environment variables to always allow (bypass redaction). | `[]` |
|
||||
| Blocked Environment Variables | `security.environmentVariableRedaction.blocked` | Environment variables to always redact. | `[]` |
|
||||
|
||||
### Experimental
|
||||
|
||||
@@ -110,3 +111,4 @@ they appear in the UI.
|
||||
| ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | ------- |
|
||||
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
|
||||
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
|
||||
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Agent Skills
|
||||
|
||||
_Note: This is an experimental feature enabled via `experimental.skills`. You
|
||||
can also search for "Skills" within the `/settings` interactive UI to toggle
|
||||
this and manage other skill-related settings._
|
||||
|
||||
Agent Skills allow you to extend Gemini CLI with specialized expertise,
|
||||
procedural workflows, and task-specific resources. Based on the
|
||||
[Agent Skills](https://agentskills.io) open standard, a "skill" is a
|
||||
self-contained directory that packages instructions and assets into a
|
||||
discoverable capability.
|
||||
|
||||
## Overview
|
||||
|
||||
Unlike general context files ([`GEMINI.md`](./gemini-md.md)), which provide
|
||||
persistent project-wide background, Skills represent **on-demand expertise**.
|
||||
This allows Gemini to maintain a vast library of specialized capabilities—such
|
||||
as security auditing, cloud deployments, or codebase migrations—without
|
||||
cluttering the model's immediate context window.
|
||||
|
||||
Gemini autonomously decides when to employ a skill based on your request and the
|
||||
skill's description. When a relevant skill is identified, the model "pulls in"
|
||||
the full instructions and resources required to complete the task using the
|
||||
`activate_skill` tool.
|
||||
|
||||
## Key Benefits
|
||||
|
||||
- **Shared Expertise:** Package complex workflows (like a specific team's PR
|
||||
review process) into a folder that anyone can use.
|
||||
- **Repeatable Workflows:** Ensure complex multi-step tasks are performed
|
||||
consistently by providing a procedural framework.
|
||||
- **Resource Bundling:** Include scripts, templates, or example data alongside
|
||||
instructions so the agent has everything it needs.
|
||||
- **Progressive Disclosure:** Only skill metadata (name and description) is
|
||||
loaded initially. Detailed instructions and resources are only disclosed when
|
||||
the model explicitly activates the skill, saving context tokens.
|
||||
|
||||
## Skill Discovery Tiers
|
||||
|
||||
Gemini CLI discovers skills from three primary locations:
|
||||
|
||||
1. **Project Skills** (`.gemini/skills/`): Project-specific skills that are
|
||||
typically committed to version control and shared with the team.
|
||||
2. **User Skills** (`~/.gemini/skills/`): Personal skills available across all
|
||||
your projects.
|
||||
3. **Extension Skills**: Skills bundled within installed
|
||||
[extensions](../extensions/index.md).
|
||||
|
||||
**Precedence:** If multiple skills share the same name, higher-precedence
|
||||
locations override lower ones: **Project > User > Extension**.
|
||||
|
||||
## Managing Skills
|
||||
|
||||
### In an Interactive Session
|
||||
|
||||
Use the `/skills` slash command to view and manage available expertise:
|
||||
|
||||
- `/skills list` (default): Shows all discovered skills and their status.
|
||||
- `/skills disable <name>`: Prevents a specific skill from being used.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
|
||||
|
||||
_Note: `/skills disable` and `/skills enable` default to the `user` scope. Use
|
||||
`--scope project` to manage project-specific settings._
|
||||
|
||||
### From the Terminal
|
||||
|
||||
The `gemini skills` command provides management utilities:
|
||||
|
||||
```bash
|
||||
# List all discovered skills
|
||||
gemini skills list
|
||||
|
||||
# Enable/disable skills. Can use --scope to specify project or user
|
||||
gemini skills enable my-expertise
|
||||
gemini skills disable my-expertise
|
||||
```
|
||||
|
||||
## Creating a Skill
|
||||
|
||||
A skill is a directory containing a `SKILL.md` file at its root. This file uses
|
||||
YAML frontmatter for metadata and Markdown for instructions.
|
||||
|
||||
### Basic Structure
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: <unique-name>
|
||||
description: <what the skill does and when Gemini should use it>
|
||||
---
|
||||
|
||||
<your instructions for how the agent should behave / use the skill>
|
||||
```
|
||||
|
||||
- **`name`**: A unique identifier (lowercase, alphanumeric, and dashes).
|
||||
- **`description`**: The most critical field. Gemini uses this to decide when
|
||||
the skill is relevant. Be specific about the expertise provided.
|
||||
- **Body**: Everything below the second `---` is injected as expert procedural
|
||||
guidance for the model.
|
||||
|
||||
### Example: Team Code Reviewer
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description:
|
||||
Expertise in reviewing code for style, security, and performance. Use when the
|
||||
user asks for "feedback," a "review," or to "check" their changes.
|
||||
---
|
||||
|
||||
# Code Reviewer
|
||||
|
||||
You are an expert code reviewer. When reviewing code, follow this workflow:
|
||||
|
||||
1. **Analyze**: Review the staged changes or specific files provided. Ensure
|
||||
that the changes are scoped properly and represent minimal changes required
|
||||
to address the issue.
|
||||
2. **Style**: Ensure code follows the project's conventions and idiomatic
|
||||
patterns as described in the `GEMINI.md` file.
|
||||
3. **Security**: Flag any potential security vulnerabilities.
|
||||
4. **Tests**: Verify that new logic has corresponding test coverage and that
|
||||
the test coverage adequately validates the changes.
|
||||
|
||||
Provide your feedback as a concise bulleted list of "Strengths" and
|
||||
"Opportunities."
|
||||
```
|
||||
|
||||
### Resource Conventions
|
||||
|
||||
While you can structure your skill directory however you like, the Agent Skills
|
||||
standard encourages these conventions:
|
||||
|
||||
- **`scripts/`**: Executable scripts (bash, python, node) the agent can run.
|
||||
- **`references/`**: Static documentation, schemas, or example data for the
|
||||
agent to consult.
|
||||
- **`assets/`**: Code templates, boilerplate, or binary resources.
|
||||
|
||||
When a skill is activated, Gemini CLI provides the model with a tree view of the
|
||||
entire skill directory, allowing it to discover and utilize these assets.
|
||||
|
||||
## How it Works (Security & Privacy)
|
||||
|
||||
1. **Discovery**: At the start of a session, Gemini CLI scans the discovery
|
||||
tiers and injects the name and description of all enabled skills into the
|
||||
system prompt.
|
||||
2. **Activation**: When Gemini identifies a task matching a skill's
|
||||
description, it calls the `activate_skill` tool.
|
||||
3. **Consent**: You will see a confirmation prompt in the UI detailing the
|
||||
skill's name, purpose, and the directory path it will gain access to.
|
||||
4. **Injection**: Upon your approval:
|
||||
- The `SKILL.md` body and folder structure is added to the conversation
|
||||
history.
|
||||
- The skill's directory is added to the agent's allowed file paths, granting
|
||||
it permission to read any bundled assets.
|
||||
5. **Execution**: The model proceeds with the specialized expertise active. It
|
||||
is instructed to prioritize the skill's procedural guidance within reason.
|
||||
@@ -0,0 +1,93 @@
|
||||
# System Prompt Override (GEMINI_SYSTEM_MD)
|
||||
|
||||
The core system instructions that guide Gemini CLI can be completely replaced
|
||||
with your own Markdown file. This feature is controlled via the
|
||||
`GEMINI_SYSTEM_MD` environment variable.
|
||||
|
||||
## Overview
|
||||
|
||||
The `GEMINI_SYSTEM_MD` variable instructs the CLI to use an external Markdown
|
||||
file for its system prompt, completely overriding the built-in default. This is
|
||||
a full replacement, not a merge. If you use a custom file, none of the original
|
||||
core instructions will apply unless you include them yourself.
|
||||
|
||||
This feature is intended for advanced users who need to enforce strict,
|
||||
project-specific behavior or create a customized persona.
|
||||
|
||||
> Tip: You can export the current default system prompt to a file first, review
|
||||
> it, and then selectively modify or replace it (see
|
||||
> [“Export the default prompt”](#export-the-default-prompt-recommended)).
|
||||
|
||||
## How to enable
|
||||
|
||||
You can set the environment variable temporarily in your shell, or persist it
|
||||
via a `.gemini/.env` file. See
|
||||
[Persisting Environment Variables](../get-started/authentication.md#persisting-environment-variables).
|
||||
|
||||
- Use the project default path (`.gemini/system.md`):
|
||||
- `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1`
|
||||
- The CLI reads `./.gemini/system.md` (relative to your current project
|
||||
directory).
|
||||
|
||||
- Use a custom file path:
|
||||
- `GEMINI_SYSTEM_MD=/absolute/path/to/my-system.md`
|
||||
- Relative paths are supported and resolved from the current working
|
||||
directory.
|
||||
- Tilde expansion is supported (e.g., `~/my-system.md`).
|
||||
|
||||
- Disable the override (use built‑in prompt):
|
||||
- `GEMINI_SYSTEM_MD=false` or `GEMINI_SYSTEM_MD=0` or unset the variable.
|
||||
|
||||
If the override is enabled but the target file does not exist, the CLI will
|
||||
error with: `missing system prompt file '<path>'`.
|
||||
|
||||
## Quick examples
|
||||
|
||||
- One‑off session using a project file:
|
||||
- `GEMINI_SYSTEM_MD=1 gemini`
|
||||
- Persist for a project using `.gemini/.env`:
|
||||
- Create `.gemini/system.md`, then add to `.gemini/.env`:
|
||||
- `GEMINI_SYSTEM_MD=1`
|
||||
- Use a custom file under your home directory:
|
||||
- `GEMINI_SYSTEM_MD=~/prompts/SYSTEM.md gemini`
|
||||
|
||||
## UI indicator
|
||||
|
||||
When `GEMINI_SYSTEM_MD` is active, the CLI shows a `|⌐■_■|` indicator in the UI
|
||||
to signal custom system‑prompt mode.
|
||||
|
||||
## Export the default prompt (recommended)
|
||||
|
||||
Before overriding, export the current default prompt so you can review required
|
||||
safety and workflow rules.
|
||||
|
||||
- Write the built‑in prompt to the project default path:
|
||||
- `GEMINI_WRITE_SYSTEM_MD=1 gemini`
|
||||
- Or write to a custom path:
|
||||
- `GEMINI_WRITE_SYSTEM_MD=~/prompts/DEFAULT_SYSTEM.md gemini`
|
||||
|
||||
This creates the file and writes the current built‑in system prompt to it.
|
||||
|
||||
## Best practices: SYSTEM.md vs GEMINI.md
|
||||
|
||||
- SYSTEM.md (firmware):
|
||||
- Non‑negotiable operational rules: safety, tool‑use protocols, approvals, and
|
||||
mechanics that keep the CLI reliable.
|
||||
- Stable across tasks and projects (or per project when needed).
|
||||
- GEMINI.md (strategy):
|
||||
- Persona, goals, methodologies, and project/domain context.
|
||||
- Evolves per task; relies on SYSTEM.md for safe execution.
|
||||
|
||||
Keep SYSTEM.md minimal but complete for safety and tool operation. Keep
|
||||
GEMINI.md focused on high‑level guidance and project specifics.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Error: `missing system prompt file '…'`
|
||||
- Ensure the referenced path exists and is readable.
|
||||
- For `GEMINI_SYSTEM_MD=1|true`, create `./.gemini/system.md` in your project.
|
||||
- Override not taking effect
|
||||
- Confirm the variable is loaded (use `.gemini/.env` or export in your shell).
|
||||
- Paths are resolved from the current working directory; try an absolute path.
|
||||
- Restore defaults
|
||||
- Unset `GEMINI_SYSTEM_MD` or set it to `0`/`false`.
|
||||
@@ -297,7 +297,7 @@ Captures startup configuration and user prompt submissions.
|
||||
|
||||
#### Tools
|
||||
|
||||
Captures tool executions, output truncation, and Smart Edit behavior.
|
||||
Captures tool executions, output truncation, and Edit behavior.
|
||||
|
||||
- `gemini_cli.tool_call`: Emitted for each tool (function) call.
|
||||
- **Attributes**:
|
||||
@@ -325,11 +325,11 @@ Captures tool executions, output truncation, and Smart Edit behavior.
|
||||
- `lines` (int)
|
||||
- `prompt_id` (string)
|
||||
|
||||
- `gemini_cli.smart_edit_strategy`: Smart Edit strategy chosen.
|
||||
- `gemini_cli.edit_strategy`: Edit strategy chosen.
|
||||
- **Attributes**:
|
||||
- `strategy` (string)
|
||||
|
||||
- `gemini_cli.smart_edit_correction`: Smart Edit correction result.
|
||||
- `gemini_cli.edit_correction`: Edit correction result.
|
||||
- **Attributes**:
|
||||
- `correction` ("success" | "failure")
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
This page contains tutorials for interacting with Gemini CLI.
|
||||
|
||||
## Agent Skills
|
||||
|
||||
- [Getting Started with Agent Skills](./tutorials/skills-getting-started.md)
|
||||
|
||||
## Setting up a Model Context Protocol (MCP) server
|
||||
|
||||
> [!CAUTION] Before using a third-party MCP server, ensure you trust its source
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Getting Started with Agent Skills
|
||||
|
||||
Agent Skills allow you to extend Gemini CLI with specialized expertise. This
|
||||
tutorial will guide you through creating your first skill, enabling it, and
|
||||
using it in a session.
|
||||
|
||||
## 1. Enable Agent Skills
|
||||
|
||||
Agent Skills are currently an experimental feature and must be enabled in your
|
||||
settings.
|
||||
|
||||
### Via the interactive UI
|
||||
|
||||
1. Start a Gemini CLI session by running `gemini`.
|
||||
2. Type `/settings` to open the interactive settings dialog.
|
||||
3. Search for "Skills".
|
||||
4. Toggle **Agent Skills** to `true`.
|
||||
5. Press `Esc` to save and exit. You may need to restart the CLI for the
|
||||
changes to take effect.
|
||||
|
||||
### Via `settings.json`
|
||||
|
||||
Alternatively, you can manually edit your global settings file at
|
||||
`~/.gemini/settings.json` (create it if it doesn't exist):
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"skills": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Create Your First Skill
|
||||
|
||||
A skill is a directory containing a `SKILL.md` file. Let's create an **API
|
||||
Auditor** skill that helps you verify if local or remote endpoints are
|
||||
responding correctly.
|
||||
|
||||
1. **Create the skill directory structure:**
|
||||
|
||||
```bash
|
||||
mkdir -p .gemini/skills/api-auditor/scripts
|
||||
```
|
||||
|
||||
2. **Create the `SKILL.md` file:** Create a file at
|
||||
`.gemini/skills/api-auditor/SKILL.md` with the following content:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: api-auditor
|
||||
description:
|
||||
Expertise in auditing and testing API endpoints. Use when the user asks to
|
||||
"check", "test", or "audit" a URL or API.
|
||||
---
|
||||
|
||||
# API Auditor Instructions
|
||||
|
||||
You act as a QA engineer specialized in API reliability. When this skill is
|
||||
active, you MUST:
|
||||
|
||||
1. **Audit**: Use the bundled `scripts/audit.js` utility to check the
|
||||
status of the provided URL.
|
||||
2. **Report**: Analyze the output (status codes, latency) and explain any
|
||||
failures in plain English.
|
||||
3. **Secure**: Remind the user if they are testing a sensitive endpoint
|
||||
without an `https://` protocol.
|
||||
```
|
||||
|
||||
3. **Create the bundled Node.js script:** Create a file at
|
||||
`.gemini/skills/api-auditor/scripts/audit.js`. This script will be used by
|
||||
the agent to perform the actual check:
|
||||
|
||||
```javascript
|
||||
// .gemini/skills/api-auditor/scripts/audit.js
|
||||
const url = process.argv[2];
|
||||
|
||||
if (!url) {
|
||||
console.error('Usage: node audit.js <url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Auditing ${url}...`);
|
||||
fetch(url, { method: 'HEAD' })
|
||||
.then((r) => console.log(`Result: Success (Status ${r.status})`))
|
||||
.catch((e) => console.error(`Result: Failed (${e.message})`));
|
||||
```
|
||||
|
||||
## 3. Verify the Skill is Discovered
|
||||
|
||||
Use the `/skills` slash command (or `gemini skills list` from your terminal) to
|
||||
see if Gemini CLI has found your new skill.
|
||||
|
||||
In a Gemini CLI session:
|
||||
|
||||
```
|
||||
/skills list
|
||||
```
|
||||
|
||||
You should see `api-auditor` in the list of available skills.
|
||||
|
||||
## 4. Use the Skill in a Chat
|
||||
|
||||
Now, let's see the skill in action. Start a new session and ask a question about
|
||||
an endpoint.
|
||||
|
||||
**User:** "Can you audit http://geminili.com"
|
||||
|
||||
Gemini will recognize the request matches the `api-auditor` description and will
|
||||
ask for your permission to activate it.
|
||||
|
||||
**Model:** (After calling `activate_skill`) "I've activated the **api-auditor**
|
||||
skill. I'll run the audit script now..."
|
||||
|
||||
Gemini will then use the `run_shell_command` tool to execute your bundled Node
|
||||
script:
|
||||
|
||||
`node .gemini/skills/api-auditor/scripts/audit.js http://geminili.com`
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Explore [Agent Skills Authoring Guide](../skills.md#creating-a-skill) to learn
|
||||
about more advanced skill features.
|
||||
- Learn how to share skills via [Extensions](../../extensions/index.md).
|
||||
@@ -367,6 +367,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"extends": "chat-base-2.5",
|
||||
"modelConfig": {
|
||||
@@ -496,6 +502,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"chat-compression-3-flash": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"chat-compression-2.5-pro": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-pro"
|
||||
@@ -610,6 +621,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
shell command. Defaults to 5 minutes.
|
||||
- **Default:** `300`
|
||||
|
||||
- **`tools.shell.enableShellOutputEfficiency`** (boolean):
|
||||
- **Description:** Enable shell output efficiency optimizations for better
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.autoAccept`** (boolean):
|
||||
- **Description:** Automatically accept and execute tool calls that are
|
||||
considered safe (e.g., read-only operations).
|
||||
@@ -668,20 +684,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `1000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.enableMessageBusIntegration`** (boolean):
|
||||
- **Description:** Enable policy-based tool confirmation via message bus
|
||||
integration. When enabled, tools automatically respect policy engine
|
||||
decisions (ALLOW/DENY/ASK_USER) without requiring individual tool
|
||||
implementations.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.enableHooks`** (boolean):
|
||||
- **Description:** Enable the hooks system for intercepting and customizing
|
||||
Gemini CLI behavior. When enabled, hooks configured in settings will execute
|
||||
at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.).
|
||||
Requires MessageBus integration.
|
||||
- **Default:** `false`
|
||||
- **Description:** Enables the hooks system experiment. When disabled, the
|
||||
hooks system is completely deactivated regardless of other settings.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `mcp`
|
||||
@@ -701,12 +707,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `useSmartEdit`
|
||||
|
||||
- **`useSmartEdit`** (boolean):
|
||||
- **Description:** Enable the smart-edit tool instead of the replace tool.
|
||||
- **Default:** `true`
|
||||
|
||||
#### `useWriteTodos`
|
||||
|
||||
- **`useWriteTodos`** (boolean):
|
||||
@@ -720,6 +720,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.enablePermanentToolApproval`** (boolean):
|
||||
- **Description:** Enable the "Allow for all future sessions" option in tool
|
||||
confirmation dialogs.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`security.blockGitExtensions`** (boolean):
|
||||
- **Description:** Blocks installing and loading extensions from Git.
|
||||
- **Default:** `false`
|
||||
@@ -730,6 +735,22 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.environmentVariableRedaction.allowed`** (array):
|
||||
- **Description:** Environment variables to always allow (bypass redaction).
|
||||
- **Default:** `[]`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.environmentVariableRedaction.blocked`** (array):
|
||||
- **Description:** Environment variables to always redact.
|
||||
- **Default:** `[]`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.environmentVariableRedaction.enabled`** (boolean):
|
||||
- **Description:** Enable redaction of environment variables that may contain
|
||||
secrets.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.auth.selectedType`** (string):
|
||||
- **Description:** The currently selected authentication type.
|
||||
- **Default:** `undefined`
|
||||
@@ -773,7 +794,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents.
|
||||
- **Description:** Enable local and remote subagents. Warning: Experimental
|
||||
feature, uses YOLO mode for subagents
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -787,13 +809,13 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.isModelAvailabilityServiceEnabled`** (boolean):
|
||||
- **Description:** Enable model routing using new availability service.
|
||||
- **`experimental.jitContext`** (boolean):
|
||||
- **Description:** Enable Just-In-Time (JIT) context loading.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.jitContext`** (boolean):
|
||||
- **Description:** Enable Just-In-Time (JIT) context loading.
|
||||
- **`experimental.skills`** (boolean):
|
||||
- **Description:** Enable Agent Skills (experimental).
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -821,16 +843,42 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.codebaseInvestigatorSettings.model`** (string):
|
||||
- **Description:** The model to use for the Codebase Investigator agent.
|
||||
- **Default:** `"pro"`
|
||||
- **Default:** `"auto"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.useOSC52Paste`** (boolean):
|
||||
- **Description:** Use OSC 52 sequence for pasting instead of clipboardy
|
||||
(useful for remote sessions).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.introspectionAgentSettings.enabled`** (boolean):
|
||||
- **Description:** Enable the Introspection Agent.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `skills`
|
||||
|
||||
- **`skills.disabled`** (array):
|
||||
- **Description:** List of disabled skills.
|
||||
- **Default:** `[]`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `hooks`
|
||||
|
||||
- **`hooks.enabled`** (boolean):
|
||||
- **Description:** Canonical toggle for the hooks system. When disabled, no
|
||||
hooks will be executed.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`hooks.disabled`** (array):
|
||||
- **Description:** List of hook names (commands) that should be disabled.
|
||||
Hooks in this list will not execute even if configured.
|
||||
- **Default:** `[]`
|
||||
|
||||
- **`hooks.notifications`** (boolean):
|
||||
- **Description:** Show visual indicators when hooks are executing.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`hooks.BeforeTool`** (array):
|
||||
- **Description:** Hooks that execute before tool execution. Can intercept,
|
||||
validate, or modify tool calls.
|
||||
@@ -885,6 +933,21 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Hooks that execute before tool selection. Can filter or
|
||||
prioritize available tools dynamically.
|
||||
- **Default:** `[]`
|
||||
|
||||
#### `admin`
|
||||
|
||||
- **`admin.secureModeEnabled`** (boolean):
|
||||
- **Description:** If true, disallows yolo mode from being used.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`admin.extensions.enabled`** (boolean):
|
||||
- **Description:** If false, disallows extensions from being installed or
|
||||
used.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`admin.mcp.enabled`** (boolean):
|
||||
- **Description:** If false, disallows MCP servers from being used.
|
||||
- **Default:** `true`
|
||||
<!-- SETTINGS-AUTOGEN:END -->
|
||||
|
||||
#### `mcpServers`
|
||||
@@ -1117,6 +1180,18 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- **`GEMINI_SANDBOX`**:
|
||||
- Alternative to the `sandbox` setting in `settings.json`.
|
||||
- Accepts `true`, `false`, `docker`, `podman`, or a custom command string.
|
||||
- **`GEMINI_SYSTEM_MD`**:
|
||||
- Replaces the built‑in system prompt with content from a Markdown file.
|
||||
- `true`/`1`: Use project default path `./.gemini/system.md`.
|
||||
- Any other string: Treat as a path (relative/absolute supported, `~`
|
||||
expands).
|
||||
- `false`/`0` or unset: Use the built‑in prompt. See
|
||||
[System Prompt Override](../cli/system-prompt.md).
|
||||
- **`GEMINI_WRITE_SYSTEM_MD`**:
|
||||
- Writes the current built‑in system prompt to a file for review.
|
||||
- `true`/`1`: Write to `./.gemini/system.md`. Otherwise treat the value as a
|
||||
path.
|
||||
- Run the CLI once with this set to generate the file.
|
||||
- **`SEATBELT_PROFILE`** (macOS specific):
|
||||
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
|
||||
- `permissive-open`: (Default) Restricts writes to the project folder (and a
|
||||
@@ -1142,6 +1217,52 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Specifies the endpoint for the code assist server.
|
||||
- This is useful for development and testing.
|
||||
|
||||
### Environment variable redaction
|
||||
|
||||
To prevent accidental leakage of sensitive information, Gemini CLI automatically
|
||||
redacts potential secrets from environment variables when executing tools (such
|
||||
as shell commands). This "best effort" redaction applies to variables inherited
|
||||
from the system or loaded from `.env` files.
|
||||
|
||||
**Default Redaction Rules:**
|
||||
|
||||
- **By Name:** Variables are redacted if their names contain sensitive terms
|
||||
like `TOKEN`, `SECRET`, `PASSWORD`, `KEY`, `AUTH`, `CREDENTIAL`, `PRIVATE`, or
|
||||
`CERT`.
|
||||
- **By Value:** Variables are redacted if their values match known secret
|
||||
patterns, such as:
|
||||
- Private keys (RSA, OpenSSH, PGP, etc.)
|
||||
- Certificates
|
||||
- URLs containing credentials
|
||||
- API keys and tokens (GitHub, Google, AWS, Stripe, Slack, etc.)
|
||||
- **Specific Blocklist:** Certain variables like `CLIENT_ID`, `DB_URI`,
|
||||
`DATABASE_URL`, and `CONNECTION_STRING` are always redacted by default.
|
||||
|
||||
**Allowlist (Never Redacted):**
|
||||
|
||||
- Common system variables (e.g., `PATH`, `HOME`, `USER`, `SHELL`, `TERM`,
|
||||
`LANG`).
|
||||
- Variables starting with `GEMINI_CLI_`.
|
||||
- GitHub Action specific variables.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
You can customize this behavior in your `settings.json` file:
|
||||
|
||||
- **`security.allowedEnvironmentVariables`**: A list of variable names to
|
||||
_never_ redact, even if they match sensitive patterns.
|
||||
- **`security.blockedEnvironmentVariables`**: A list of variable names to
|
||||
_always_ redact, even if they don't match sensitive patterns.
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"allowedEnvironmentVariables": ["MY_PUBLIC_KEY", "NOT_A_SECRET_TOKEN"],
|
||||
"blockedEnvironmentVariables": ["INTERNAL_IP_ADDRESS"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Command-line arguments
|
||||
|
||||
Arguments passed directly when running the CLI can override other configurations
|
||||
|
||||
@@ -24,7 +24,7 @@ Rename the photos in my "photos" directory based on their contents.
|
||||
|
||||
Result: Gemini will ask for permission to rename your files.
|
||||
|
||||
Select **Yes, allow once.** and your files will be renamed:
|
||||
Select **Allow once** and your files will be renamed:
|
||||
|
||||
```bash
|
||||
photos/yellow_flowers.png
|
||||
|
||||
@@ -1,56 +1,37 @@
|
||||
# Gemini 3 Pro on Gemini CLI
|
||||
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
|
||||
|
||||
We’re excited to bring Gemini 3 Pro to Gemini CLI. Gemini 3 Pro is **currently
|
||||
available** on Gemini CLI to all of the following subscribers:
|
||||
Gemini 3 Pro and Gemini 3 Flash are now available on Gemini CLI! Currently, most
|
||||
paid customers of Gemini CLI will have access to both Gemini 3 Pro and Gemini 3
|
||||
Flash, including the following subscribers:
|
||||
|
||||
- Google AI Ultra (except Google AI Ultra for Business).
|
||||
- Google AI Pro.
|
||||
- Gemini Code Assist Standard (requires
|
||||
- Google AI Pro and Google AI Ultra (excluding business customers).
|
||||
- Gemini Code Assist Standard and Enterprise (requires
|
||||
[administrative enablement](#administrator-instructions)).
|
||||
- Gemini Code Assist Enterprise (requires
|
||||
[administrative enablement](#administrator-instructions)).
|
||||
- Paid Gemini API key holders.
|
||||
- Paid Vertex API key holders.
|
||||
- Paid Gemini API and Vertex API key holders.
|
||||
|
||||
For **everyone else**, we're gradually expanding access
|
||||
[through a waitlist](https://goo.gle/geminicli-waitlist-signup). If you don't
|
||||
have one of the listed subscriptions, sign up for the waitlist to access Gemini
|
||||
3 Pro once approved.
|
||||
For free tier users:
|
||||
|
||||
**Note:** Whether you’re automatically granted access or accepted from the
|
||||
waitlist, you’ll still need to enable Gemini 3 Pro
|
||||
[using the `/settings` command](../cli/settings.md).
|
||||
- If you signed up for the waitlist, please check your email for details. We’ve
|
||||
onboarded everyone who signed up to the previously available waitlist.
|
||||
- If you were not on our waitlist, we’re rolling out additional access gradually
|
||||
to ensure the experience remains fast and reliable. Stay tuned for more
|
||||
details.
|
||||
|
||||
## How to join the waitlist
|
||||
## How to get started with Gemini 3 on Gemini CLI
|
||||
|
||||
Users not automatically granted access will need to join the waitlist. Follow
|
||||
these instructions to sign up:
|
||||
Get started by upgrading Gemini CLI to the latest version (0.21.1):
|
||||
|
||||
- Install Gemini CLI.
|
||||
- Authenticate using the **Login with Google** option. You’ll see a banner that
|
||||
says “Gemini 3 is now available.” If you do not see this banner, update your
|
||||
installation of Gemini CLI to the most recent version.
|
||||
- Fill out this Google form:
|
||||
[Access Gemini 3 in Gemini CLI](https://goo.gle/geminicli-waitlist-signup).
|
||||
Provide the email address of the account you used to authenticate with Gemini
|
||||
CLI.
|
||||
```bash
|
||||
npm install -g @google/gemini-cli@latest
|
||||
```
|
||||
|
||||
Users will be onboarded in batches, subject to availability. When you’ve been
|
||||
granted access to Gemini 3 Pro, you’ll receive an acceptance email to your
|
||||
submitted email address.
|
||||
After you’ve confirmed your version is 0.21.1 or later:
|
||||
|
||||
**Note:** Please wait until you have been approved to use Gemini 3 Pro to enable
|
||||
**Preview Features**. If enabled early, the CLI will fallback to Gemini 2.5 Pro.
|
||||
1. Use the `/settings` command in Gemini CLI.
|
||||
2. Toggle **Preview Features** to `true`.
|
||||
3. Run `/model` and select **Auto (Gemini 3)**.
|
||||
|
||||
## How to use Gemini 3 Pro with Gemini CLI
|
||||
|
||||
Once you receive your acceptance email–or if you are automatically granted
|
||||
access–you still need to enable Gemini 3 Pro within Gemini CLI.
|
||||
|
||||
To enable Gemini 3 Pro, use the `/settings` command in Gemini CLI and set
|
||||
**Preview Features** to `true`.
|
||||
|
||||
For more information, see [Gemini CLI Settings](../cli/settings.md).
|
||||
For more information, see [Gemini CLI model selection](../cli/model.md).
|
||||
|
||||
### Usage limits and fallback
|
||||
|
||||
@@ -68,10 +49,10 @@ There may be times when the Gemini 3 Pro model is overloaded. When that happens,
|
||||
Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro
|
||||
or fallback to Gemini 2.5 Pro.
|
||||
|
||||
**Note:** The **Keep trying** option uses exponential backoff, in which Gemini
|
||||
CLI waits longer between each retry, when the system is busy. If the retry
|
||||
doesn't happen immediately, please wait a few minutes for the request to
|
||||
process.
|
||||
> **Note:** The **Keep trying** option uses exponential backoff, in which Gemini
|
||||
> CLI waits longer between each retry, when the system is busy. If the retry
|
||||
> doesn't happen immediately, please wait a few minutes for the request to
|
||||
> process.
|
||||
|
||||
### Model selection and routing types
|
||||
|
||||
@@ -92,7 +73,7 @@ manage your usage limits:
|
||||
To learn more about selecting a model and routing, refer to
|
||||
[Gemini CLI Model Selection](../cli/model.md).
|
||||
|
||||
## How to enable Gemini 3 Pro with Gemini CLI on Gemini Code Assist
|
||||
## How to enable Gemini 3 with Gemini CLI on Gemini Code Assist
|
||||
|
||||
If you're using Gemini Code Assist Standard or Gemini Code Assist Enterprise,
|
||||
enabling Gemini 3 Pro on Gemini CLI requires configuring your release channels.
|
||||
@@ -123,7 +104,7 @@ then:
|
||||
- Use the `/settings` command.
|
||||
- Set **Preview Features** to `true`.
|
||||
|
||||
Restart Gemini CLI and you should have access to Gemini 3 Pro.
|
||||
Restart Gemini CLI and you should have access to Gemini 3.
|
||||
|
||||
## Need help?
|
||||
|
||||
|
||||
+183
-133
@@ -4,128 +4,6 @@ This guide covers security considerations, performance optimization, debugging
|
||||
techniques, and privacy considerations for developing and deploying hooks in
|
||||
Gemini CLI.
|
||||
|
||||
## Security considerations
|
||||
|
||||
### Validate all inputs
|
||||
|
||||
Never trust data from hooks without validation. Hook inputs may contain
|
||||
user-provided data that could be malicious:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
|
||||
# Validate JSON structure
|
||||
if ! echo "$input" | jq empty 2>/dev/null; then
|
||||
echo "Invalid JSON input" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate required fields
|
||||
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
|
||||
if [ -z "$tool_name" ]; then
|
||||
echo "Missing tool_name field" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Use timeouts
|
||||
|
||||
Set reasonable timeouts to prevent hooks from hanging indefinitely:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "slow-validator",
|
||||
"type": "command",
|
||||
"command": "./hooks/validate.sh",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Recommended timeouts:**
|
||||
|
||||
- Fast validation: 1000-5000ms
|
||||
- Network requests: 10000-30000ms
|
||||
- Heavy computation: 30000-60000ms
|
||||
|
||||
### Limit permissions
|
||||
|
||||
Run hooks with minimal required permissions:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Don't run as root
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
echo "Hook should not run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check file permissions before writing
|
||||
if [ -w "$file_path" ]; then
|
||||
# Safe to write
|
||||
else
|
||||
echo "Insufficient permissions" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Scan for secrets
|
||||
|
||||
Use `BeforeTool` hooks to prevent committing sensitive data:
|
||||
|
||||
```javascript
|
||||
const SECRET_PATTERNS = [
|
||||
/api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
|
||||
/password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i,
|
||||
/secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
|
||||
/AKIA[0-9A-Z]{16}/, // AWS access key
|
||||
/ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token
|
||||
/sk-[a-zA-Z0-9]{48}/, // OpenAI API key
|
||||
];
|
||||
|
||||
function containsSecret(content) {
|
||||
return SECRET_PATTERNS.some((pattern) => pattern.test(content));
|
||||
}
|
||||
```
|
||||
|
||||
### Review external scripts
|
||||
|
||||
Always review hook scripts from untrusted sources before enabling them:
|
||||
|
||||
```bash
|
||||
# Review before installing
|
||||
cat third-party-hook.sh | less
|
||||
|
||||
# Check for suspicious patterns
|
||||
grep -E 'curl|wget|ssh|eval' third-party-hook.sh
|
||||
|
||||
# Verify hook source
|
||||
ls -la third-party-hook.sh
|
||||
```
|
||||
|
||||
### Sandbox untrusted hooks
|
||||
|
||||
For maximum security, consider running untrusted hooks in isolated environments:
|
||||
|
||||
```bash
|
||||
# Run hook in Docker container
|
||||
docker run --rm \
|
||||
-v "$GEMINI_PROJECT_DIR:/workspace:ro" \
|
||||
-i untrusted-hook-image \
|
||||
/hook-script.sh < input.json
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Keep hooks fast
|
||||
@@ -140,11 +18,13 @@ const data2 = await fetch(url2).then((r) => r.json());
|
||||
const data3 = await fetch(url3).then((r) => r.json());
|
||||
|
||||
// Prefer parallel operations for better performance
|
||||
const [data1, data2, data3] = await Promise.all([
|
||||
fetch(url1).then((r) => r.json()),
|
||||
fetch(url2).then((r) => r.json()),
|
||||
fetch(url3).then((r) => r.json()),
|
||||
]);
|
||||
// Start requests concurrently
|
||||
const p1 = fetch(url1).then((r) => r.json());
|
||||
const p2 = fetch(url2).then((r) => r.json());
|
||||
const p3 = fetch(url3).then((r) => r.json());
|
||||
|
||||
// Wait for all results
|
||||
const [data1, data2, data3] = await Promise.all([p1, p2, p3]);
|
||||
```
|
||||
|
||||
### Cache expensive operations
|
||||
@@ -219,7 +99,7 @@ all tools with `*`, specify only the tools you need:
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "WriteFile|Edit",
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "validate-writes",
|
||||
@@ -298,7 +178,7 @@ cat > test-input.json << 'EOF'
|
||||
"session_id": "test-123",
|
||||
"cwd": "/tmp/test",
|
||||
"hook_event_name": "BeforeTool",
|
||||
"tool_name": "WriteFile",
|
||||
"tool_name": "write_file",
|
||||
"tool_input": {
|
||||
"file_path": "test.txt",
|
||||
"content": "Test content"
|
||||
@@ -437,7 +317,7 @@ Add descriptions to help others understand your hooks:
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "WriteFile|Edit",
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "secret-scanner",
|
||||
@@ -484,7 +364,7 @@ Verify the hook appears in the list and is enabled.
|
||||
|
||||
```bash
|
||||
# Test regex pattern
|
||||
echo "WriteFile" | grep -E "Write.*|Edit"
|
||||
echo "write_file|replace" | grep -E "write_.*|replace"
|
||||
```
|
||||
|
||||
**Check disabled list:**
|
||||
@@ -559,7 +439,7 @@ async function getCachedData(key) {
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "WriteFile",
|
||||
"matcher": "write_file",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "quick-check",
|
||||
@@ -569,7 +449,7 @@ async function getCachedData(key) {
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "WriteFile",
|
||||
"matcher": "write_file",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "deep-check",
|
||||
@@ -714,6 +594,176 @@ if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then
|
||||
fi
|
||||
```
|
||||
|
||||
## Using Hooks Securely
|
||||
|
||||
### Threat Model
|
||||
|
||||
Understanding where hooks come from and what they can do is critical for secure
|
||||
usage.
|
||||
|
||||
| Hook Source | Description |
|
||||
| :---------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **System** | Configured by system administrators (e.g., `/etc/gemini-cli/settings.json`, `/Library/...`). Assumed to be the **safest**. |
|
||||
| **User** (`~/.gemini/...`) | Configured by you. You are responsible for ensuring they are safe. |
|
||||
| **Extensions** | You explicitly approve and install these. Security depends on the extension source (integrity). |
|
||||
| **Project** (`./.gemini/...`) | **Untrusted by default.** Safest in trusted internal repos; higher risk in third-party/public repos. |
|
||||
|
||||
#### Project Hook Security
|
||||
|
||||
When you open a project with hooks defined in `.gemini/settings.json`:
|
||||
|
||||
1. **Detection**: Gemini CLI detects the hooks.
|
||||
2. **Identification**: A unique identity is generated for each hook based on its
|
||||
`name` and `command`.
|
||||
3. **Warning**: If this specific hook identity has not been seen before, a
|
||||
**warning** is displayed.
|
||||
4. **Execution**: The hook is executed (unless specific security settings block
|
||||
it).
|
||||
5. **Trust**: The hook is marked as "trusted" for this project.
|
||||
|
||||
> [!IMPORTANT] **Modification Detection**: If the `command` string of a project
|
||||
> hook is changed (e.g., by a `git pull`), its identity changes. Gemini CLI will
|
||||
> treat it as a **new, untrusted hook** and warn you again. This prevents
|
||||
> malicious actors from silently swapping a verified command for a malicious
|
||||
> one.
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Description |
|
||||
| :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Arbitrary Code Execution** | Hooks run as your user. They can do anything you can do (delete files, install software). |
|
||||
| **Data Exfiltration** | A hook could read your input (prompts), output (code), or environment variables (`GEMINI_API_KEY`) and send them to a remote server. |
|
||||
| **Prompt Injection** | Malicious content in a file or web page could trick an LLM into running a tool that triggers a hook in an unexpected way. |
|
||||
|
||||
### Mitigation Strategies
|
||||
|
||||
#### Verify the source
|
||||
|
||||
**Verify the source** of any project hooks or extensions before enabling them.
|
||||
|
||||
- For open-source projects, a quick review of the hook scripts is recommended.
|
||||
- For extensions, ensure you trust the author or publisher (e.g., verified
|
||||
publishers, well-known community members).
|
||||
- Be cautious with obfuscated scripts or compiled binaries from unknown sources.
|
||||
|
||||
#### Sanitize Environment
|
||||
|
||||
Hooks inherit the environment of the Gemini CLI process, which may include
|
||||
sensitive API keys. Gemini CLI attempts to sanitize sensitive variables, but you
|
||||
should be cautious.
|
||||
|
||||
- **Avoid printing environment variables** to stdout/stderr unless necessary.
|
||||
- **Use `.env` files** to securely manage sensitive variables, ensuring they are
|
||||
excluded from version control.
|
||||
|
||||
**System Administrators:** You can enforce environment variable redaction by
|
||||
default in the system configuration (e.g., `/etc/gemini-cli/settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"environmentVariableRedaction": {
|
||||
"enabled": true,
|
||||
"blocked": ["MY_SECRET_KEY"],
|
||||
"allowed": ["SAFE_VAR"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authoring Secure Hooks
|
||||
|
||||
When writing your own hooks, follow these practices to ensure they are robust
|
||||
and secure.
|
||||
|
||||
### Validate all inputs
|
||||
|
||||
Never trust data from hooks without validation. Hook inputs often come from the
|
||||
LLM or user prompts, which can be manipulated.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
|
||||
# Validate JSON structure
|
||||
if ! echo "$input" | jq empty 2>/dev/null; then
|
||||
echo "Invalid JSON input" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate tool_name explicitly
|
||||
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
|
||||
if [[ "$tool_name" != "write_file" && "$tool_name" != "read_file" ]]; then
|
||||
echo "Unexpected tool: $tool_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Use timeouts
|
||||
|
||||
Prevent denial-of-service (hanging agents) by enforcing timeouts. Gemini CLI
|
||||
defaults to 60 seconds, but you should set stricter limits for fast hooks.
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "fast-validator",
|
||||
"command": "./hooks/validate.sh",
|
||||
"timeout": 5000 // 5 seconds
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Limit permissions
|
||||
|
||||
Run hooks with minimal required permissions:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Don't run as root
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
echo "Hook should not run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check file permissions before writing
|
||||
if [ -w "$file_path" ]; then
|
||||
# Safe to write
|
||||
else
|
||||
echo "Insufficient permissions" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Example: Secret Scanner
|
||||
|
||||
Use `BeforeTool` hooks to prevent committing sensitive data. This is a powerful
|
||||
pattern for enhancing security in your workflow.
|
||||
|
||||
```javascript
|
||||
const SECRET_PATTERNS = [
|
||||
/api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
|
||||
/password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i,
|
||||
/secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
|
||||
/AKIA[0-9A-Z]{16}/, // AWS access key
|
||||
/ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token
|
||||
/sk-[a-zA-Z0-9]{48}/, // OpenAI API key
|
||||
];
|
||||
|
||||
function containsSecret(content) {
|
||||
return SECRET_PATTERNS.some((pattern) => pattern.test(content));
|
||||
}
|
||||
```
|
||||
|
||||
## Privacy considerations
|
||||
|
||||
Hook inputs and outputs may contain sensitive information. Gemini CLI respects
|
||||
|
||||
+150
-23
@@ -7,6 +7,9 @@ the CLI's source code.
|
||||
See [writing hooks guide](writing-hooks.md) for a tutorial on creating your
|
||||
first hook and a comprehensive example.
|
||||
|
||||
See [hooks reference](reference.md) for the technical specification of the I/O
|
||||
schemas.
|
||||
|
||||
See [best practices](best-practices.md) for guidelines on security, performance,
|
||||
and debugging.
|
||||
|
||||
@@ -24,6 +27,28 @@ With hooks, you can:
|
||||
Hooks run synchronously as part of the agent loop—when a hook event fires,
|
||||
Gemini CLI waits for all matching hooks to complete before continuing.
|
||||
|
||||
## Security and Risks
|
||||
|
||||
> [!WARNING] **Hooks execute arbitrary code with your user privileges.**
|
||||
|
||||
By configuring hooks, you are explicitly allowing Gemini CLI to run shell
|
||||
commands on your machine. Malicious or poorly configured hooks can:
|
||||
|
||||
- **Exfiltrate data**: Read sensitive files (`.env`, ssh keys) and send them to
|
||||
remote servers.
|
||||
- **Modify system**: Delete files, install malware, or change system settings.
|
||||
- **Consume resources**: Run infinite loops or crash your system.
|
||||
|
||||
**Project-level hooks** (in `.gemini/settings.json`) and **Extension hooks** are
|
||||
particularly risky when opening third-party projects or extensions from
|
||||
untrusted authors. Gemini CLI will **warn you** the first time it detects a new
|
||||
project hook (identified by its name and command), but it is **your
|
||||
responsibility** to review these hooks (and any installed extensions) before
|
||||
trusting them.
|
||||
|
||||
See [Security Considerations](best-practices.md#using-hooks-securely) for a
|
||||
detailed threat model and mitigation strategies.
|
||||
|
||||
## Core concepts
|
||||
|
||||
### Hook events
|
||||
@@ -70,7 +95,7 @@ trigger the hook:
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "WriteFile|Edit",
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
/* hooks for write operations */
|
||||
]
|
||||
@@ -82,8 +107,8 @@ trigger the hook:
|
||||
|
||||
**Matcher patterns:**
|
||||
|
||||
- **Exact match:** `"ReadFile"` matches only `ReadFile`
|
||||
- **Regex:** `"Write.*|Edit"` matches `WriteFile`, `WriteBinary`, `Edit`
|
||||
- **Exact match:** `"read_file"` matches only `read_file`
|
||||
- **Regex:** `"write_.*|replace"` matches `write_file`, `replace`
|
||||
- **Wildcard:** `"*"` or `""` matches all tools
|
||||
|
||||
**Session event matchers:**
|
||||
@@ -115,6 +140,7 @@ Every hook receives these base fields:
|
||||
```json
|
||||
{
|
||||
"session_id": "abc123",
|
||||
"transcript_path": "/path/to/transcript.jsonl",
|
||||
"cwd": "/path/to/project",
|
||||
"hook_event_name": "BeforeTool",
|
||||
"timestamp": "2025-12-01T10:30:00Z"
|
||||
@@ -130,7 +156,7 @@ Every hook receives these base fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "WriteFile",
|
||||
"tool_name": "write_file",
|
||||
"tool_input": {
|
||||
"file_path": "/path/to/file.ts",
|
||||
"content": "..."
|
||||
@@ -159,7 +185,7 @@ Or simple exit codes:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "ReadFile",
|
||||
"tool_name": "read_file",
|
||||
"tool_input": { "file_path": "..." },
|
||||
"tool_response": "file contents..."
|
||||
}
|
||||
@@ -212,7 +238,7 @@ Or simple exit codes:
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "AUTO",
|
||||
"allowedFunctionNames": ["ReadFile", "WriteFile"]
|
||||
"allowedFunctionNames": ["read_file", "write_file"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,7 +342,7 @@ Or simple exit codes:
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "ANY",
|
||||
"allowedFunctionNames": ["ReadFile", "WriteFile", "Edit"]
|
||||
"allowedFunctionNames": ["read_file", "write_file", "replace"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,7 +352,7 @@ Or simple exit codes:
|
||||
Or simple output (comma-separated tool names sets mode to ANY):
|
||||
|
||||
```bash
|
||||
echo "ReadFile,WriteFile,Edit"
|
||||
echo "read_file,write_file,replace"
|
||||
```
|
||||
|
||||
#### SessionStart
|
||||
@@ -410,13 +436,22 @@ precedence rules.
|
||||
|
||||
### Configuration layers
|
||||
|
||||
Hook configurations are applied in the following order of precedence (higher
|
||||
numbers override lower numbers):
|
||||
Hook configurations are applied in the following order of execution (lower
|
||||
numbers run first):
|
||||
|
||||
1. **System defaults:** Built-in default settings (lowest precedence)
|
||||
2. **User settings:** `~/.gemini/settings.json`
|
||||
3. **Project settings:** `.gemini/settings.json` in your project directory
|
||||
4. **System settings:** `/etc/gemini-cli/settings.json` (highest precedence)
|
||||
1. **Project settings:** `.gemini/settings.json` in your project directory
|
||||
(highest priority)
|
||||
2. **User settings:** `~/.gemini/settings.json`
|
||||
3. **System settings:** `/etc/gemini-cli/settings.json`
|
||||
4. **Extensions:** Internal hooks defined by installed extensions (lowest
|
||||
priority)
|
||||
|
||||
#### Deduplication and shadowing
|
||||
|
||||
If multiple hooks with the identical **name** and **command** are discovered
|
||||
across different configuration layers, Gemini CLI deduplicates them. The hook
|
||||
from the higher-priority layer (e.g., Project) will be kept, and others will be
|
||||
ignored.
|
||||
|
||||
Within each level, hooks run in the order they are declared in the
|
||||
configuration.
|
||||
@@ -446,8 +481,9 @@ configuration.
|
||||
|
||||
**Configuration properties:**
|
||||
|
||||
- **`name`** (string, required): Unique identifier for the hook used in
|
||||
`/hooks enable/disable` commands
|
||||
- **`name`** (string, recommended): Unique identifier for the hook used in
|
||||
`/hooks enable/disable` commands. If omitted, the `command` path is used as
|
||||
the identifier.
|
||||
- **`type`** (string, required): Hook type - currently only `"command"` is
|
||||
supported
|
||||
- **`command`** (string, required): Path to the script or command to execute
|
||||
@@ -494,6 +530,8 @@ You can temporarily enable or disable individual hooks using commands:
|
||||
|
||||
These commands allow you to control hook execution without editing configuration
|
||||
files. The hook name should match the `name` field in your hook configuration.
|
||||
Changes made via these commands are persisted to your global User settings
|
||||
(`~/.gemini/settings.json`).
|
||||
|
||||
### Disabled hooks configuration
|
||||
|
||||
@@ -524,7 +562,7 @@ This command:
|
||||
|
||||
- Reads `.claude/settings.json`
|
||||
- Converts event names (`PreToolUse` → `BeforeTool`, etc.)
|
||||
- Translates tool names (`Bash` → `RunShellCommand`, `Edit` → `Edit`)
|
||||
- Translates tool names (`Bash` → `run_shell_command`, `replace` → `replace`)
|
||||
- Updates matcher patterns
|
||||
- Writes to `.gemini/settings.json`
|
||||
|
||||
@@ -543,12 +581,101 @@ This command:
|
||||
|
||||
### Tool name mapping
|
||||
|
||||
| Claude Code | Gemini CLI |
|
||||
| ----------- | ----------------- |
|
||||
| `Bash` | `RunShellCommand` |
|
||||
| `Edit` | `Edit` |
|
||||
| `Read` | `ReadFile` |
|
||||
| `Write` | `WriteFile` |
|
||||
| Claude Code | Gemini CLI |
|
||||
| ----------- | --------------------- |
|
||||
| `Bash` | `run_shell_command` |
|
||||
| `Edit` | `replace` |
|
||||
| `Read` | `read_file` |
|
||||
| `Write` | `write_file` |
|
||||
| `Glob` | `glob` |
|
||||
| `Grep` | `search_file_content` |
|
||||
| `LS` | `list_directory` |
|
||||
|
||||
## Tool and Event Matchers Reference
|
||||
|
||||
### Available tool names for matchers
|
||||
|
||||
The following built-in tools can be used in `BeforeTool` and `AfterTool` hook
|
||||
matchers:
|
||||
|
||||
#### File operations
|
||||
|
||||
- `read_file` - Read a single file
|
||||
- `read_many_files` - Read multiple files at once
|
||||
- `write_file` - Create or overwrite a file
|
||||
- `replace` - Edit file content with find/replace
|
||||
|
||||
#### File system
|
||||
|
||||
- `list_directory` - List directory contents
|
||||
- `glob` - Find files matching a pattern
|
||||
- `search_file_content` - Search within file contents
|
||||
|
||||
#### Execution
|
||||
|
||||
- `run_shell_command` - Execute shell commands
|
||||
|
||||
#### Web and external
|
||||
|
||||
- `google_web_search` - Google Search with grounding
|
||||
- `web_fetch` - Fetch web page content
|
||||
|
||||
#### Agent features
|
||||
|
||||
- `write_todos` - Manage TODO items
|
||||
- `save_memory` - Save information to memory
|
||||
- `delegate_to_agent` - Delegate tasks to sub-agents
|
||||
|
||||
#### Example matchers
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "write_file|replace" // File editing tools
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "read_.*" // All read operations
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "run_shell_command" // Only shell commands
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "*" // All tools
|
||||
}
|
||||
```
|
||||
|
||||
### Event-specific matchers
|
||||
|
||||
#### SessionStart event matchers
|
||||
|
||||
- `startup` - Fresh session start
|
||||
- `resume` - Resuming a previous session
|
||||
- `clear` - Session cleared
|
||||
|
||||
#### SessionEnd event matchers
|
||||
|
||||
- `exit` - Normal exit
|
||||
- `clear` - Session cleared
|
||||
- `logout` - User logged out
|
||||
- `prompt_input_exit` - Exit from prompt input
|
||||
- `other` - Other reasons
|
||||
|
||||
#### PreCompress event matchers
|
||||
|
||||
- `manual` - Manually triggered compression
|
||||
- `auto` - Automatically triggered compression
|
||||
|
||||
#### Notification event matchers
|
||||
|
||||
- `ToolPermission` - Tool permission notifications
|
||||
|
||||
## Learn more
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Hooks Reference
|
||||
|
||||
This document provides the technical specification for Gemini CLI hooks,
|
||||
including the JSON schemas for input and output, exit code behaviors, and the
|
||||
stable model API.
|
||||
|
||||
## Communication Protocol
|
||||
|
||||
Hooks communicate with Gemini CLI via standard streams and exit codes:
|
||||
|
||||
- **Input**: Gemini CLI sends a JSON object to the hook's `stdin`.
|
||||
- **Output**: The hook sends a JSON object (or plain text) to `stdout`.
|
||||
- **Exit Codes**: Used to signal success or blocking errors.
|
||||
|
||||
### Exit Code Behavior
|
||||
|
||||
| Exit Code | Meaning | Behavior |
|
||||
| :-------- | :----------------- | :---------------------------------------------------------------------------------------------- |
|
||||
| `0` | **Success** | `stdout` is parsed as JSON. If parsing fails, it's treated as a `systemMessage`. |
|
||||
| `2` | **Blocking Error** | Interrupts the current operation. `stderr` is shown to the agent (for tool events) or the user. |
|
||||
| Other | **Warning** | Execution continues. `stderr` is logged as a non-blocking warning. |
|
||||
|
||||
---
|
||||
|
||||
## Input Schema (`stdin`)
|
||||
|
||||
Every hook receives a base JSON object. Extra fields are added depending on the
|
||||
specific event.
|
||||
|
||||
### Base Fields (All Events)
|
||||
|
||||
| Field | Type | Description |
|
||||
| :---------------- | :------- | :---------------------------------------------------- |
|
||||
| `session_id` | `string` | Unique identifier for the current CLI session. |
|
||||
| `transcript_path` | `string` | Path to the session's JSON transcript (if available). |
|
||||
| `cwd` | `string` | The current working directory. |
|
||||
| `hook_event_name` | `string` | The name of the firing event (e.g., `BeforeTool`). |
|
||||
| `timestamp` | `string` | ISO 8601 timestamp of the event. |
|
||||
|
||||
### Event-Specific Fields
|
||||
|
||||
#### Tool Events (`BeforeTool`, `AfterTool`)
|
||||
|
||||
- `tool_name`: (`string`) The internal name of the tool (e.g., `write_file`,
|
||||
`run_shell_command`).
|
||||
- `tool_input`: (`object`) The arguments passed to the tool.
|
||||
- `tool_response`: (`object`, **AfterTool only**) The raw output from the tool
|
||||
execution.
|
||||
|
||||
#### Agent Events (`BeforeAgent`, `AfterAgent`)
|
||||
|
||||
- `prompt`: (`string`) The user's submitted prompt.
|
||||
- `prompt_response`: (`string`, **AfterAgent only**) The final response text
|
||||
from the model.
|
||||
- `stop_hook_active`: (`boolean`, **AfterAgent only**) Indicates if a stop hook
|
||||
is already handling a continuation.
|
||||
|
||||
#### Model Events (`BeforeModel`, `AfterModel`, `BeforeToolSelection`)
|
||||
|
||||
- `llm_request`: (`LLMRequest`) A stable representation of the outgoing request.
|
||||
See [Stable Model API](#stable-model-api).
|
||||
- `llm_response`: (`LLMResponse`, **AfterModel only**) A stable representation
|
||||
of the incoming response.
|
||||
|
||||
#### Session & Notification Events
|
||||
|
||||
- `source`: (`startup` | `resume` | `clear`, **SessionStart only**) The trigger
|
||||
source.
|
||||
- `reason`: (`exit` | `clear` | `logout` | `prompt_input_exit` | `other`,
|
||||
**SessionEnd only**) The reason for session end.
|
||||
- `trigger`: (`manual` | `auto`, **PreCompress only**) What triggered the
|
||||
compression event.
|
||||
- `notification_type`: (`ToolPermission`, **Notification only**) The type of
|
||||
notification being fired.
|
||||
- `message`: (`string`, **Notification only**) The notification message.
|
||||
- `details`: (`object`, **Notification only**) Payload-specific details for the
|
||||
notification.
|
||||
|
||||
---
|
||||
|
||||
## Output Schema (`stdout`)
|
||||
|
||||
If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON.
|
||||
|
||||
### Common Output Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| :------------------- | :-------- | :----------------------------------------------------------------------- |
|
||||
| `decision` | `string` | One of: `allow`, `deny`, `block`, `ask`, `approve`. |
|
||||
| `reason` | `string` | Explanation shown to the **agent** when a decision is `deny` or `block`. |
|
||||
| `systemMessage` | `string` | Message displayed to the **user** in the CLI terminal. |
|
||||
| `continue` | `boolean` | If `false`, immediately terminates the agent loop for this turn. |
|
||||
| `stopReason` | `string` | Message shown to the user when `continue` is `false`. |
|
||||
| `suppressOutput` | `boolean` | If `true`, the hook execution is hidden from the CLI transcript. |
|
||||
| `hookSpecificOutput` | `object` | Container for event-specific data (see below). |
|
||||
|
||||
### `hookSpecificOutput` Reference
|
||||
|
||||
| Field | Supported Events | Description |
|
||||
| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||
| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. |
|
||||
| `llm_request` | `BeforeModel` | A `Partial<LLMRequest>` to override parameters of the outgoing call. |
|
||||
| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. |
|
||||
| `llm_response` | `AfterModel` | A `Partial<LLMResponse>` to modify the model's response before the agent sees it. |
|
||||
| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. |
|
||||
|
||||
---
|
||||
|
||||
## Stable Model API
|
||||
|
||||
Gemini CLI uses a decoupled format for model interactions to ensure hooks remain
|
||||
stable even if the underlying Gemini SDK changes.
|
||||
|
||||
### `LLMRequest` Object
|
||||
|
||||
Used in `BeforeModel` and `BeforeToolSelection`.
|
||||
|
||||
> 💡 **Note**: In v1, model hooks are primarily text-focused. Non-text parts
|
||||
> (like images or function calls) provided in the `content` array will be
|
||||
> simplified to their string representation by the translator.
|
||||
|
||||
```typescript
|
||||
{
|
||||
"model": string,
|
||||
"messages": Array<{
|
||||
"role": "user" | "model" | "system",
|
||||
"content": string | Array<{ "type": string, [key: string]: any }>
|
||||
}>,
|
||||
"config"?: {
|
||||
"temperature"?: number,
|
||||
"maxOutputTokens"?: number,
|
||||
"topP"?: number,
|
||||
"topK"?: number
|
||||
},
|
||||
"toolConfig"?: {
|
||||
"mode"?: "AUTO" | "ANY" | "NONE",
|
||||
"allowedFunctionNames"?: string[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `LLMResponse` Object
|
||||
|
||||
Used in `AfterModel` and as a synthetic response in `BeforeModel`.
|
||||
|
||||
```typescript
|
||||
{
|
||||
"text"?: string,
|
||||
"candidates": Array<{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": string[]
|
||||
},
|
||||
"finishReason"?: "STOP" | "MAX_TOKENS" | "SAFETY" | "RECITATION" | "OTHER",
|
||||
"index"?: number,
|
||||
"safetyRatings"?: Array<{
|
||||
"category": string,
|
||||
"probability": string,
|
||||
"blocked"?: boolean
|
||||
}>
|
||||
}>,
|
||||
"usageMetadata"?: {
|
||||
"promptTokenCount"?: number,
|
||||
"candidatesTokenCount"?: number,
|
||||
"totalTokenCount"?: number
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -72,9 +72,9 @@ Run Gemini CLI and execute any command that uses tools:
|
||||
```
|
||||
> Read the README.md file
|
||||
|
||||
[Agent uses ReadFile tool]
|
||||
[Agent uses read_file tool]
|
||||
|
||||
Logged: ReadFile
|
||||
Logged: read_file
|
||||
```
|
||||
|
||||
Check `.gemini/tool-log.txt` to see the logged tool executions.
|
||||
@@ -110,7 +110,7 @@ exit 0
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "WriteFile|Edit",
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "secret-scanner",
|
||||
@@ -167,7 +167,7 @@ exit 0
|
||||
"hooks": {
|
||||
"AfterTool": [
|
||||
{
|
||||
"matcher": "WriteFile|Edit",
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "auto-test",
|
||||
@@ -379,7 +379,7 @@ chmod +x .gemini/hooks/*.js
|
||||
],
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "WriteFile|Edit",
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "security-check",
|
||||
@@ -392,7 +392,7 @@ chmod +x .gemini/hooks/*.js
|
||||
],
|
||||
"AfterTool": [
|
||||
{
|
||||
"matcher": "WriteFile|Edit",
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "auto-test",
|
||||
@@ -601,7 +601,7 @@ async function main() {
|
||||
.map((k) => k.trim());
|
||||
|
||||
// Simple keyword-based filtering + core tools
|
||||
const coreTools = ['ReadFile', 'WriteFile', 'Edit', 'RunShellCommand'];
|
||||
const coreTools = ['read_file', 'write_file', 'replace', 'run_shell_command'];
|
||||
const filtered = candidateTools.filter((tool) => {
|
||||
if (coreTools.includes(tool)) return true;
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
@@ -128,8 +128,8 @@ editor.
|
||||
You can also **modify the suggested changes** directly in the diff view before
|
||||
accepting them.
|
||||
|
||||
If you select ‘Yes, allow always’ in the CLI, changes will no longer show up in
|
||||
the IDE as they will be auto-accepted.
|
||||
If you select ‘Allow for this session’ in the CLI, changes will no longer show
|
||||
up in the IDE as they will be auto-accepted.
|
||||
|
||||
## Using with sandboxing
|
||||
|
||||
|
||||
@@ -73,6 +73,9 @@ This documentation is organized into the following sections:
|
||||
- **[Memport](./core/memport.md):** Using the Memory Import Processor.
|
||||
- **[Tools API](./core/tools-api.md):** Information on how the core manages and
|
||||
exposes tools.
|
||||
- **[System Prompt Override](./cli/system-prompt.md):** Replace built-in system
|
||||
instructions using `GEMINI_SYSTEM_MD`.
|
||||
|
||||
- **[Policy Engine](./core/policy-engine.md):** Use the Policy Engine for
|
||||
fine-grained control over tool execution.
|
||||
|
||||
|
||||
+13
-1
@@ -24,7 +24,7 @@
|
||||
"slug": "docs/get-started"
|
||||
},
|
||||
{
|
||||
"label": "Gemini 3 Pro on Gemini CLI",
|
||||
"label": "Gemini 3 on Gemini CLI",
|
||||
"slug": "docs/get-started/gemini-3"
|
||||
},
|
||||
{
|
||||
@@ -88,6 +88,10 @@
|
||||
"label": "Session Management",
|
||||
"slug": "docs/cli/session-management"
|
||||
},
|
||||
{
|
||||
"label": "Agent Skills",
|
||||
"slug": "docs/cli/skills"
|
||||
},
|
||||
{
|
||||
"label": "Settings",
|
||||
"slug": "docs/cli/settings"
|
||||
@@ -115,6 +119,10 @@
|
||||
{
|
||||
"label": "Uninstall",
|
||||
"slug": "docs/cli/uninstall"
|
||||
},
|
||||
{
|
||||
"label": "System prompt override",
|
||||
"slug": "docs/cli/system-prompt"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -204,6 +212,10 @@
|
||||
"label": "Writing hooks",
|
||||
"slug": "docs/hooks/writing-hooks"
|
||||
},
|
||||
{
|
||||
"label": "Hooks reference",
|
||||
"slug": "docs/hooks/reference"
|
||||
},
|
||||
{
|
||||
"label": "Best practices",
|
||||
"slug": "docs/hooks/best-practices"
|
||||
|
||||
+19
-10
@@ -12,7 +12,7 @@ import prettierConfig from 'eslint-config-prettier';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import vitest from '@vitest/eslint-plugin';
|
||||
import globals from 'globals';
|
||||
import licenseHeader from 'eslint-plugin-license-header';
|
||||
import headers from 'eslint-plugin-headers';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
|
||||
@@ -164,7 +164,9 @@ export default tseslint.config(
|
||||
'prefer-arrow-callback': 'error',
|
||||
'prefer-const': ['error', { destructuring: 'all' }],
|
||||
radix: 'error',
|
||||
'no-console': 'error',
|
||||
'default-case': 'error',
|
||||
'@typescript-eslint/await-thenable': ['error'],
|
||||
'@typescript-eslint/no-floating-promises': ['error'],
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': ['error'],
|
||||
},
|
||||
@@ -208,19 +210,26 @@ export default tseslint.config(
|
||||
{
|
||||
files: ['./**/*.{tsx,ts,js}'],
|
||||
plugins: {
|
||||
'license-header': licenseHeader,
|
||||
headers,
|
||||
import: importPlugin,
|
||||
},
|
||||
rules: {
|
||||
'license-header/header': [
|
||||
'headers/header-format': [
|
||||
'error',
|
||||
[
|
||||
'/**',
|
||||
' * @license',
|
||||
' * Copyright 2025 Google LLC',
|
||||
' * SPDX-License-Identifier: Apache-2.0',
|
||||
' */',
|
||||
],
|
||||
{
|
||||
source: 'string',
|
||||
content: [
|
||||
'@license',
|
||||
'Copyright (year) Google LLC',
|
||||
'SPDX-License-Identifier: Apache-2.0',
|
||||
].join('\n'),
|
||||
patterns: {
|
||||
year: {
|
||||
pattern: '202[5-6]',
|
||||
defaultValue: '2026',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
'import/enforce-node-protocol-usage': ['error', 'always'],
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('Interactive Mode', () => {
|
||||
await run.sendKeys(
|
||||
'Write a 200 word story about a robot. The story MUST end with the text THE_END followed by a period.',
|
||||
);
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for the specific end marker.
|
||||
await run.expectText('THE_END.', 30000);
|
||||
|
||||
@@ -95,7 +95,10 @@ describe('extension reloading', () => {
|
||||
|
||||
// Poll for the updated list
|
||||
await rig.pollCommand(
|
||||
() => run.sendKeys('\u0015/mcp list\r'),
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
@@ -110,9 +113,9 @@ describe('extension reloading', () => {
|
||||
// Update the extension, expect the list to update, and mcp servers as well.
|
||||
await run.sendKeys('\u0015/extensions update test-extension');
|
||||
await run.expectText('/extensions update test-extension');
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
await run.expectText(
|
||||
` * test-server (remote): http://localhost:${portB}/mcp`,
|
||||
);
|
||||
@@ -123,7 +126,10 @@ describe('extension reloading', () => {
|
||||
|
||||
// Poll for the updated extension version
|
||||
await rig.pollCommand(
|
||||
() => run.sendKeys('\u0015/extensions list\r'),
|
||||
async () => {
|
||||
await run.sendText('/extensions list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() =>
|
||||
stripAnsi(run.output).includes(
|
||||
'test-extension (v0.0.2) - active (updated)',
|
||||
@@ -133,7 +139,10 @@ describe('extension reloading', () => {
|
||||
|
||||
// Poll for the updated mcp tool
|
||||
await rig.pollCommand(
|
||||
() => run.sendKeys('\u0015/mcp list\r'),
|
||||
async () => {
|
||||
await run.sendText('/mcp list');
|
||||
await run.type('\r');
|
||||
},
|
||||
() => {
|
||||
const output = stripAnsi(run.output);
|
||||
return (
|
||||
@@ -146,7 +155,7 @@ describe('extension reloading', () => {
|
||||
);
|
||||
|
||||
await run.sendText('/quit');
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
|
||||
// Clean things up.
|
||||
await serverA.stop();
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Interactive file system', () => {
|
||||
// Step 1: Read the file
|
||||
const readPrompt = `Read the version from ${fileName}`;
|
||||
await run.type(readPrompt);
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
|
||||
const readCall = await rig.waitForToolCall('read_file', 30000);
|
||||
expect(readCall, 'Expected to find a read_file tool call').toBe(true);
|
||||
@@ -45,7 +45,7 @@ describe('Interactive file system', () => {
|
||||
// Step 2: Write the file
|
||||
const writePrompt = `now change the version to 1.0.1 in the file`;
|
||||
await run.type(writePrompt);
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
|
||||
// Check tool calls made with right args
|
||||
await rig.expectToolCallSuccess(
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_dir","args":{"path":"."}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Final Answer"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Responding**\n\nI will respond to the user's request.\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120,"promptTokensDetails":[{"modality":"TEXT","tokenCount":100}],"thoughtsTokenCount":20}},{"candidates":[{"content":{"parts":[{"text":"Response to: "}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":5,"totalTokenCount":125,"promptTokensDetails":[{"modality":"TEXT","tokenCount":100}],"thoughtsTokenCount":20}},{"candidates":[{"content":{"parts":[{"text":"Hello World"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":7,"totalTokenCount":127,"promptTokensDetails":[{"modality":"TEXT","tokenCount":100}],"thoughtsTokenCount":20}}]}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
describe('Hooks Agent Flow', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (rig) {
|
||||
await rig.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe('BeforeAgent Hooks', () => {
|
||||
it('should inject additional context via BeforeAgent hook', async () => {
|
||||
await rig.setup('should inject additional context via BeforeAgent hook', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-agent-flow.responses',
|
||||
),
|
||||
});
|
||||
|
||||
const hookScript = `
|
||||
try {
|
||||
const output = {
|
||||
decision: "allow",
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "BeforeAgent",
|
||||
additionalContext: "SYSTEM INSTRUCTION: This is injected context."
|
||||
}
|
||||
};
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
} catch (e) {
|
||||
console.error('Failed to write stdout:', e);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error('DEBUG: BeforeAgent hook executed');
|
||||
`;
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'before_agent_context.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should inject additional context via BeforeAgent hook', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await rig.run({ args: 'Hello test' });
|
||||
|
||||
// Verify hook execution and telemetry
|
||||
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
|
||||
expect(hookTelemetryFound).toBeTruthy();
|
||||
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const beforeAgentLog = hookLogs.find(
|
||||
(log) => log.hookCall.hook_event_name === 'BeforeAgent',
|
||||
);
|
||||
|
||||
expect(beforeAgentLog).toBeDefined();
|
||||
expect(beforeAgentLog?.hookCall.stdout).toContain('injected context');
|
||||
expect(beforeAgentLog?.hookCall.stdout).toContain('"decision":"allow"');
|
||||
expect(beforeAgentLog?.hookCall.stdout).toContain(
|
||||
'SYSTEM INSTRUCTION: This is injected context.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AfterAgent Hooks', () => {
|
||||
it('should receive prompt and response in AfterAgent hook', async () => {
|
||||
await rig.setup('should receive prompt and response in AfterAgent hook', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-agent-flow.responses',
|
||||
),
|
||||
});
|
||||
|
||||
const hookScript = `
|
||||
const fs = require('fs');
|
||||
try {
|
||||
const input = fs.readFileSync(0, 'utf-8');
|
||||
console.error('DEBUG: AfterAgent hook input received');
|
||||
process.stdout.write("Received Input: " + input);
|
||||
} catch (err) {
|
||||
console.error('Hook Failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
`;
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'after_agent_verify.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should receive prompt and response in AfterAgent hook', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
AfterAgent: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await rig.run({ args: 'Hello validation' });
|
||||
|
||||
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
|
||||
expect(hookTelemetryFound).toBeTruthy();
|
||||
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const afterAgentLog = hookLogs.find(
|
||||
(log) => log.hookCall.hook_event_name === 'AfterAgent',
|
||||
);
|
||||
|
||||
expect(afterAgentLog).toBeDefined();
|
||||
// Verify the hook stdout contains the input we echoed which proves the
|
||||
// hook received the prompt and response
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('Received Input');
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('Hello validation');
|
||||
// The fake response contains "Hello World"
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('Hello World');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-step Loops', () => {
|
||||
it('should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls', async () => {
|
||||
await rig.setup(
|
||||
'should fire BeforeAgent and AfterAgent exactly once per turn despite tool calls',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-agent-flow-multistep.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node -e "console.log('BeforeAgent Fired')"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
AfterAgent: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node -e "console.log('AfterAgent Fired')"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await rig.run({ args: 'Do a multi-step task' });
|
||||
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const beforeAgentLogs = hookLogs.filter(
|
||||
(log) => log.hookCall.hook_event_name === 'BeforeAgent',
|
||||
);
|
||||
const afterAgentLogs = hookLogs.filter(
|
||||
(log) => log.hookCall.hook_event_name === 'AfterAgent',
|
||||
);
|
||||
|
||||
expect(beforeAgentLogs).toHaveLength(1);
|
||||
|
||||
expect(afterAgentLogs).toHaveLength(1);
|
||||
|
||||
const afterAgentLog = afterAgentLogs[0];
|
||||
expect(afterAgentLog).toBeDefined();
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('AfterAgent Fired');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hi there!"}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Clarification: I am a bot."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Initializing File Creation**\n\nI'm starting to think about how to make a new file called `test.txt`. My plan is to use a `write_file` tool. I'll need to specify the location and what the file should contain. For now, it will be empty.\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":13216,"totalTokenCount":13269,"promptTokensDetails":[{"modality":"TEXT","tokenCount":13216}],"thoughtsTokenCount":53}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":""}},"thoughtSignature":"CiQBcsjafJ20Qbx0YvING6aZ0wYoGWJh3eqornOG4E4AfBLiVsQKXwFyyNp8UlwYs/pv9IRQQGhDlrmlOJF2hfQijryyUYLI+qjDYTpZ6KKIfZF4+vS0soL2BJ3eTXA6gaadFEfNQem3WQVeQoKLFoW4Hv4mbasXqQc0K3p15DuSAtZZENTbCnsBcsjafGK+BJyF/Npnd7gyU0TL5PXePT0nuDFjhJDxlSRUJHDP315TewD3PUYsXd10oWsfhy4B5AngyUiBPUoajdsxg8WxaxnOZYqcp8EIuwtGZrCTev6IihT5nE5jj7u0P9vtnCmkAc6p+4O7Q7Jku1uVGqeJChgzI4YKSAFyyNp8EXSdbttV4xzX+NLKkc276L8Y63tnKU6/Y7fc9/58tU29DSdrgwfe9qmvwtTsO0piFXSLazqHJt8h2bgR7A7GnKDiIA=="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":13216,"candidatesTokenCount":21,"totalTokenCount":13290,"promptTokensDetails":[{"modality":"TEXT","tokenCount":13216}],"thoughtsTokenCount":53}},{"candidates":[{"content":{"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":13216,"candidatesTokenCount":21,"totalTokenCount":13290,"promptTokensDetails":[{"modality":"TEXT","tokenCount":13216}],"thoughtsTokenCount":53}}]}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"content":"original content","file_path":"original.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have created the file."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
@@ -32,10 +32,8 @@ describe('Hooks System Integration', () => {
|
||||
'hooks-system.block-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
@@ -44,7 +42,7 @@ describe('Hooks System Integration', () => {
|
||||
{
|
||||
type: 'command',
|
||||
command:
|
||||
'echo "{\\"decision\\": \\"block\\", \\"reason\\": \\"File writing blocked by security policy\\"}"',
|
||||
"node -e \"console.log(JSON.stringify({decision: 'block', reason: 'File writing blocked by security policy'}))\"",
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
@@ -86,10 +84,8 @@ describe('Hooks System Integration', () => {
|
||||
'hooks-system.allow-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
@@ -97,7 +93,7 @@ describe('Hooks System Integration', () => {
|
||||
{
|
||||
type: 'command',
|
||||
command:
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"reason\\": \\"File writing approved\\"}"',
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'File writing approved'}))\"",
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
@@ -129,17 +125,15 @@ describe('Hooks System Integration', () => {
|
||||
describe('Command Hooks - Additional Context', () => {
|
||||
it('should add additional context from AfterTool hooks', async () => {
|
||||
const command =
|
||||
'echo "{\\"hookSpecificOutput\\": {\\"hookEventName\\": \\"AfterTool\\", \\"additionalContext\\": \\"Security scan: File content appears safe\\"}}"';
|
||||
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}))\"";
|
||||
await rig.setup('should add additional context from AfterTool hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.after-tool-context.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
AfterTool: [
|
||||
{
|
||||
matcher: 'read_file',
|
||||
@@ -190,40 +184,35 @@ describe('Hooks System Integration', () => {
|
||||
'hooks-system.before-model.responses',
|
||||
),
|
||||
});
|
||||
const hookScript = `#!/bin/bash
|
||||
echo '{
|
||||
"decision": "allow",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "BeforeModel",
|
||||
"llm_request": {
|
||||
"messages": [
|
||||
const hookScript = `const fs = require('fs');
|
||||
console.log(JSON.stringify({
|
||||
decision: "allow",
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "BeforeModel",
|
||||
llm_request: {
|
||||
messages: [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Please respond with exactly: The security hook modified this request successfully."
|
||||
role: "user",
|
||||
content: "Please respond with exactly: The security hook modified this request successfully."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}'`;
|
||||
}));`;
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'before_model_hook.sh');
|
||||
const scriptPath = join(rig.testDir!, 'before_model_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
// Make executable
|
||||
const { execSync } = await import('node:child_process');
|
||||
execSync(`chmod +x "${scriptPath}"`);
|
||||
|
||||
await rig.setup('should modify LLM requests with BeforeModel hooks', {
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeModel: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: scriptPath,
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
@@ -250,7 +239,9 @@ echo '{
|
||||
expect(hookTelemetryFound[0].hookCall.hook_event_name).toBe(
|
||||
'BeforeModel',
|
||||
);
|
||||
expect(hookTelemetryFound[0].hookCall.hook_name).toBe(scriptPath);
|
||||
expect(hookTelemetryFound[0].hookCall.hook_name).toBe(
|
||||
`node "${scriptPath}"`,
|
||||
);
|
||||
expect(hookTelemetryFound[0].hookCall.hook_input).toBeDefined();
|
||||
expect(hookTelemetryFound[0].hookCall.hook_output).toBeDefined();
|
||||
expect(hookTelemetryFound[0].hookCall.exit_code).toBe(0);
|
||||
@@ -270,43 +261,39 @@ echo '{
|
||||
),
|
||||
});
|
||||
// Create a hook script that modifies the LLM response
|
||||
const hookScript = `#!/bin/bash
|
||||
echo '{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "AfterModel",
|
||||
"llm_response": {
|
||||
"candidates": [
|
||||
const hookScript = `const fs = require('fs');
|
||||
console.log(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "AfterModel",
|
||||
llm_response: {
|
||||
candidates: [
|
||||
{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
"[FILTERED] Response has been filtered for security compliance."
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
finishReason: "STOP"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}'`;
|
||||
}));`;
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'after_model_hook.sh');
|
||||
const scriptPath = join(rig.testDir!, 'after_model_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
const { execSync } = await import('node:child_process');
|
||||
execSync(`chmod +x "${scriptPath}"`);
|
||||
|
||||
await rig.setup('should modify LLM responses with AfterModel hooks', {
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
AfterModel: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: scriptPath,
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
@@ -343,17 +330,15 @@ echo '{
|
||||
);
|
||||
// Create inline hook command (works on both Unix and Windows)
|
||||
const hookCommand =
|
||||
'echo "{\\"hookSpecificOutput\\": {\\"hookEventName\\": \\"BeforeToolSelection\\", \\"toolConfig\\": {\\"mode\\": \\"ANY\\", \\"allowedFunctionNames\\": [\\"read_file\\", \\"run_shell_command\\"]}}}"';
|
||||
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'BeforeToolSelection', toolConfig: {mode: 'ANY', allowedFunctionNames: ['read_file', 'run_shell_command']}}}))\"";
|
||||
|
||||
await rig.setup(
|
||||
'should modify tool selection with BeforeToolSelection hooks',
|
||||
{
|
||||
settings: {
|
||||
debugMode: true,
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeToolSelection: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -404,32 +389,28 @@ echo '{
|
||||
),
|
||||
});
|
||||
// Create a hook script that adds context to the prompt
|
||||
const hookScript = `#!/bin/bash
|
||||
echo '{
|
||||
"decision": "allow",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "BeforeAgent",
|
||||
"additionalContext": "SYSTEM INSTRUCTION: You are in a secure environment. Always mention security compliance in your responses."
|
||||
const hookScript = `const fs = require('fs');
|
||||
console.log(JSON.stringify({
|
||||
decision: "allow",
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "BeforeAgent",
|
||||
additionalContext: "SYSTEM INSTRUCTION: You are in a secure environment. Always mention security compliance in your responses."
|
||||
}
|
||||
}'`;
|
||||
}));`;
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'before_agent_hook.sh');
|
||||
const scriptPath = join(rig.testDir!, 'before_agent_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
const { execSync } = await import('node:child_process');
|
||||
execSync(`chmod +x "${scriptPath}"`);
|
||||
|
||||
await rig.setup('should augment prompts with BeforeAgent hooks', {
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: scriptPath,
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
@@ -452,9 +433,10 @@ echo '{
|
||||
|
||||
describe('Notification Hooks - Permission Handling', () => {
|
||||
it('should handle notification hooks for tool permissions', async () => {
|
||||
// Create inline hook command (works on both Unix and Windows)
|
||||
// Create inline hook command (works on both Unix and Windows)
|
||||
const hookCommand =
|
||||
'echo "{\\"suppressOutput\\": false, \\"systemMessage\\": \\"Permission request logged by security hook\\"}"';
|
||||
'node -e "console.log(JSON.stringify({suppressOutput: false, systemMessage: \'Permission request logged by security hook\'}))"';
|
||||
|
||||
await rig.setup('should handle notification hooks for tool permissions', {
|
||||
fakeResponsesPath: join(
|
||||
@@ -464,11 +446,11 @@ echo '{
|
||||
settings: {
|
||||
// Configure tools to enable hooks and require confirmation to trigger notifications
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
approval: 'ASK', // Disable YOLO mode to show permission prompts
|
||||
confirmationRequired: ['run_shell_command'],
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
Notification: [
|
||||
{
|
||||
matcher: 'ToolPermission',
|
||||
@@ -548,9 +530,9 @@ echo '{
|
||||
it('should execute hooks sequentially when configured', async () => {
|
||||
// Create inline hook commands (works on both Unix and Windows)
|
||||
const hook1Command =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"hookSpecificOutput\\": {\\"hookEventName\\": \\"BeforeAgent\\", \\"additionalContext\\": \\"Step 1: Initial validation passed.\\"}}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 1: Initial validation passed.'}}))\"";
|
||||
const hook2Command =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"hookSpecificOutput\\": {\\"hookEventName\\": \\"BeforeAgent\\", \\"additionalContext\\": \\"Step 2: Security check completed.\\"}}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))\"";
|
||||
|
||||
await rig.setup('should execute hooks sequentially when configured', {
|
||||
fakeResponsesPath: join(
|
||||
@@ -558,10 +540,8 @@ echo '{
|
||||
'hooks-system.sequential-execution.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeAgent: [
|
||||
{
|
||||
sequential: true,
|
||||
@@ -621,36 +601,33 @@ echo '{
|
||||
),
|
||||
});
|
||||
// Create a hook script that validates the input format
|
||||
const hookScript = `#!/bin/bash
|
||||
# Read JSON input from stdin
|
||||
input=$(cat)
|
||||
const hookScript = `const fs = require('fs');
|
||||
const input = fs.readFileSync(0, 'utf-8');
|
||||
try {
|
||||
const json = JSON.parse(input);
|
||||
// Check fields
|
||||
if (json.session_id && json.cwd && json.hook_event_name && json.timestamp && json.tool_name && json.tool_input) {
|
||||
console.log(JSON.stringify({decision: "allow", reason: "Input format is correct"}));
|
||||
} else {
|
||||
console.log(JSON.stringify({decision: "block", reason: "Input format is invalid"}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify({decision: "block", reason: "Invalid JSON"}));
|
||||
}`;
|
||||
|
||||
# Check for required fields
|
||||
if echo "$input" | jq -e '.session_id and .cwd and .hook_event_name and .timestamp and .tool_name and .tool_input' > /dev/null 2>&1; then
|
||||
echo '{"decision": "allow", "reason": "Input format is correct"}'
|
||||
exit 0
|
||||
else
|
||||
echo '{"decision": "block", "reason": "Input format is invalid"}'
|
||||
exit 0
|
||||
fi`;
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'input_validation_hook.sh');
|
||||
const scriptPath = join(rig.testDir!, 'input_validation_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
const { execSync } = await import('node:child_process');
|
||||
execSync(`chmod +x "${scriptPath}"`);
|
||||
|
||||
await rig.setup('should provide correct input format to hooks', {
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: scriptPath,
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
@@ -682,11 +659,11 @@ fi`;
|
||||
it('should handle hooks for all major event types', async () => {
|
||||
// Create inline hook commands (works on both Unix and Windows)
|
||||
const beforeToolCommand =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"BeforeTool: File operation logged\\"}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'BeforeTool: File operation logged'}))\"";
|
||||
const afterToolCommand =
|
||||
'echo "{\\"hookSpecificOutput\\": {\\"hookEventName\\": \\"AfterTool\\", \\"additionalContext\\": \\"AfterTool: Operation completed successfully\\"}}"';
|
||||
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'AfterTool: Operation completed successfully'}}))\"";
|
||||
const beforeAgentCommand =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"hookSpecificOutput\\": {\\"hookEventName\\": \\"BeforeAgent\\", \\"additionalContext\\": \\"BeforeAgent: User request processed\\"}}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))\"";
|
||||
|
||||
await rig.setup('should handle hooks for all major event types', {
|
||||
fakeResponsesPath: join(
|
||||
@@ -694,10 +671,8 @@ fi`;
|
||||
'hooks-system.multiple-events.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -802,17 +777,15 @@ fi`;
|
||||
// Create a hook script that fails
|
||||
// Create inline hook commands (works on both Unix and Windows)
|
||||
// Failing hook: exits with non-zero code
|
||||
const failingCommand = 'exit 1';
|
||||
const failingCommand = 'node -e "process.exit(1)"';
|
||||
// Working hook: returns success with JSON
|
||||
const workingCommand =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"reason\\": \\"Working hook succeeded\\"}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))\"";
|
||||
|
||||
await rig.setup('should handle hook failures gracefully', {
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -855,7 +828,7 @@ fi`;
|
||||
it('should generate telemetry events for hook executions', async () => {
|
||||
// Create inline hook command (works on both Unix and Windows)
|
||||
const hookCommand =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"reason\\": \\"Telemetry test hook\\"}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))\"";
|
||||
|
||||
await rig.setup('should generate telemetry events for hook executions', {
|
||||
fakeResponsesPath: join(
|
||||
@@ -863,10 +836,8 @@ fi`;
|
||||
'hooks-system.telemetry.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -898,7 +869,7 @@ fi`;
|
||||
it('should fire SessionStart hook on app startup', async () => {
|
||||
// Create inline hook command that outputs JSON
|
||||
const sessionStartCommand =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"Session starting on startup\\"}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))\"";
|
||||
|
||||
await rig.setup('should fire SessionStart hook on app startup', {
|
||||
fakeResponsesPath: join(
|
||||
@@ -906,10 +877,8 @@ fi`;
|
||||
'hooks-system.session-startup.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: 'startup',
|
||||
@@ -955,12 +924,172 @@ fi`;
|
||||
}
|
||||
});
|
||||
|
||||
it('should fire SessionStart hook and inject context', async () => {
|
||||
// Create hook script that outputs JSON with additionalContext
|
||||
const hookScript = `const fs = require('fs');
|
||||
console.log(JSON.stringify({
|
||||
decision: 'allow',
|
||||
systemMessage: 'Context injected via SessionStart hook',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext: 'If asked about your identity, you must respond with the following: I am a protocol droid that speaks the bacchi.'
|
||||
}
|
||||
}));`;
|
||||
|
||||
await rig.setup('should fire SessionStart hook and inject context', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.session-startup.responses',
|
||||
),
|
||||
});
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'session_start_context_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should fire SessionStart hook and inject context', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: 'startup',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Run a query - the SessionStart hook will fire during app initialization
|
||||
const result = await rig.run({ args: 'Who are you?' });
|
||||
|
||||
// Check if systemMessage was displayed (in stderr, which rig.run captures)
|
||||
expect(result).toContain('Context injected via SessionStart hook');
|
||||
|
||||
// Check if additionalContext influenced the model response
|
||||
// Note: We use fake responses, but the rig records interactions.
|
||||
// If we are using fake responses, the model won't actually respond unless we provide a fake response for the injected context.
|
||||
// But the test rig setup uses 'hooks-system.session-startup.responses'.
|
||||
// If I'm adding a new test, I might need to generate new fake responses or expect the context to be sent to the model (verify API logs).
|
||||
|
||||
// Verify hook executed
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const sessionStartLog = hookLogs.find(
|
||||
(log) => log.hookCall.hook_event_name === 'SessionStart',
|
||||
);
|
||||
|
||||
expect(sessionStartLog).toBeDefined();
|
||||
|
||||
// Verify the API request contained the injected context
|
||||
// rig.readAllApiRequest() gives us telemetry on API requests.
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
// We expect at least one API request
|
||||
expect(apiRequests.length).toBeGreaterThan(0);
|
||||
|
||||
// The injected context should be in the request text
|
||||
// For non-interactive mode, I prepended it to input: "context\n\ninput"
|
||||
// The telemetry `request_text` should contain it.
|
||||
const requestText = apiRequests[0].attributes?.request_text || '';
|
||||
expect(requestText).toContain('protocol droid');
|
||||
});
|
||||
|
||||
it('should fire SessionStart hook and display systemMessage in interactive mode', async () => {
|
||||
// Create hook script that outputs JSON with systemMessage and additionalContext
|
||||
const hookScript = `const fs = require('fs');
|
||||
console.log(JSON.stringify({
|
||||
decision: 'allow',
|
||||
systemMessage: 'Interactive Session Start Message',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext: 'The user is a Jedi Master.'
|
||||
}
|
||||
}));`;
|
||||
|
||||
await rig.setup(
|
||||
'should fire SessionStart hook and display systemMessage in interactive mode',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.session-startup.responses',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const scriptPath = join(
|
||||
rig.testDir!,
|
||||
'session_start_interactive_hook.cjs',
|
||||
);
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup(
|
||||
'should fire SessionStart hook and display systemMessage in interactive mode',
|
||||
{
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: 'startup',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const run = await rig.runInteractive();
|
||||
|
||||
// Verify systemMessage is displayed
|
||||
await run.expectText('Interactive Session Start Message', 10000);
|
||||
|
||||
// Send a prompt to establish a session and trigger an API call
|
||||
await run.sendKeys('Hello');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for response to ensure API call happened
|
||||
await run.expectText('Hello', 15000);
|
||||
|
||||
// Wait for telemetry to be written to disk
|
||||
await rig.waitForTelemetryReady();
|
||||
|
||||
// Verify the API request contained the injected context
|
||||
// We may need to poll for API requests as they are written asynchronously
|
||||
const pollResult = await poll(
|
||||
() => {
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
return apiRequests.length > 0;
|
||||
},
|
||||
15000,
|
||||
500,
|
||||
);
|
||||
|
||||
expect(pollResult).toBe(true);
|
||||
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
// The injected context should be in the request_text of the API request
|
||||
const requestText = apiRequests[0].attributes?.request_text || '';
|
||||
expect(requestText).toContain('Jedi Master');
|
||||
});
|
||||
|
||||
it('should fire SessionEnd and SessionStart hooks on /clear command', async () => {
|
||||
// Create inline hook commands for both SessionEnd and SessionStart
|
||||
const sessionEndCommand =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"Session ending due to clear\\"}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session ending due to clear'}))\"";
|
||||
const sessionStartCommand =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"Session starting after clear\\"}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))\"";
|
||||
|
||||
await rig.setup(
|
||||
'should fire SessionEnd and SessionStart hooks on /clear command',
|
||||
@@ -970,10 +1099,8 @@ fi`;
|
||||
'hooks-system.session-clear.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
SessionEnd: [
|
||||
{
|
||||
matcher: '*',
|
||||
@@ -1007,7 +1134,7 @@ fi`;
|
||||
|
||||
// Send an initial prompt to establish a session
|
||||
await run.sendKeys('Say hello');
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for the response
|
||||
await run.expectText('Hello', 10000);
|
||||
@@ -1017,14 +1144,14 @@ fi`;
|
||||
const numClears = 3;
|
||||
for (let i = 0; i < numClears; i++) {
|
||||
await run.sendKeys('/clear');
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait a bit for clear to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Send a prompt to establish an active session before next clear
|
||||
await run.sendKeys('Say hello');
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for response
|
||||
await run.expectText('Hello', 10000);
|
||||
@@ -1136,7 +1263,7 @@ fi`;
|
||||
it('should fire PreCompress hook on automatic compression', async () => {
|
||||
// Create inline hook command that outputs JSON
|
||||
const preCompressCommand =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"PreCompress hook executed for automatic compression\\"}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))\"";
|
||||
|
||||
await rig.setup('should fire PreCompress hook on automatic compression', {
|
||||
fakeResponsesPath: join(
|
||||
@@ -1144,10 +1271,8 @@ fi`;
|
||||
'hooks-system.compress-auto.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
PreCompress: [
|
||||
{
|
||||
matcher: 'auto',
|
||||
@@ -1203,7 +1328,7 @@ fi`;
|
||||
describe('SessionEnd on Exit', () => {
|
||||
it('should fire SessionEnd hook on graceful exit in non-interactive mode', async () => {
|
||||
const sessionEndCommand =
|
||||
'echo "{\\"decision\\": \\"allow\\", \\"systemMessage\\": \\"SessionEnd hook executed on exit\\"}"';
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))\"";
|
||||
|
||||
await rig.setup('should fire SessionEnd hook on graceful exit', {
|
||||
fakeResponsesPath: join(
|
||||
@@ -1211,10 +1336,8 @@ fi`;
|
||||
'hooks-system.session-startup.responses',
|
||||
),
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
SessionEnd: [
|
||||
{
|
||||
matcher: 'exit',
|
||||
@@ -1297,44 +1420,39 @@ fi`;
|
||||
});
|
||||
|
||||
// Create two hook scripts - one enabled, one disabled
|
||||
const enabledHookScript = `#!/bin/bash
|
||||
echo '{"decision": "allow", "systemMessage": "Enabled hook executed"}'`;
|
||||
const enabledHookScript = `const fs = require('fs');
|
||||
console.log(JSON.stringify({decision: "allow", systemMessage: "Enabled hook executed"}));`;
|
||||
|
||||
const disabledHookScript = `#!/bin/bash
|
||||
echo '{"decision": "block", "systemMessage": "Disabled hook should not execute", "reason": "This hook should be disabled"}'`;
|
||||
const disabledHookScript = `const fs = require('fs');
|
||||
console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook should not execute", reason: "This hook should be disabled"}));`;
|
||||
|
||||
const enabledPath = join(rig.testDir!, 'enabled_hook.sh');
|
||||
const disabledPath = join(rig.testDir!, 'disabled_hook.sh');
|
||||
const enabledPath = join(rig.testDir!, 'enabled_hook.cjs');
|
||||
const disabledPath = join(rig.testDir!, 'disabled_hook.cjs');
|
||||
|
||||
writeFileSync(enabledPath, enabledHookScript);
|
||||
writeFileSync(disabledPath, disabledHookScript);
|
||||
const { execSync } = await import('node:child_process');
|
||||
execSync(`chmod +x "${enabledPath}"`);
|
||||
execSync(`chmod +x "${disabledPath}"`);
|
||||
|
||||
await rig.setup('should not execute hooks disabled in settings file', {
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: enabledPath,
|
||||
command: `node "${enabledPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
type: 'command',
|
||||
command: disabledPath,
|
||||
command: `node "${disabledPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
disabled: [disabledPath], // Disable the second hook
|
||||
disabled: [`node "${disabledPath}"`], // Disable the second hook
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1358,10 +1476,10 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
|
||||
// Check hook telemetry - only enabled hook should have executed
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const enabledHookLog = hookLogs.find(
|
||||
(log) => log.hookCall.hook_name === enabledPath,
|
||||
(log) => log.hookCall.hook_name === `node "${enabledPath}"`,
|
||||
);
|
||||
const disabledHookLog = hookLogs.find(
|
||||
(log) => log.hookCall.hook_name === disabledPath,
|
||||
(log) => log.hookCall.hook_name === `node "${disabledPath}"`,
|
||||
);
|
||||
|
||||
expect(enabledHookLog).toBeDefined();
|
||||
@@ -1380,46 +1498,41 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
|
||||
);
|
||||
|
||||
// Create two hook scripts - one that will be disabled, one that won't
|
||||
const activeHookScript = `#!/bin/bash
|
||||
echo '{"decision": "allow", "systemMessage": "Active hook executed"}'`;
|
||||
const activeHookScript = `const fs = require('fs');
|
||||
console.log(JSON.stringify({decision: "allow", systemMessage: "Active hook executed"}));`;
|
||||
|
||||
const disabledHookScript = `#!/bin/bash
|
||||
echo '{"decision": "block", "systemMessage": "Disabled hook should not execute", "reason": "This hook is disabled"}'`;
|
||||
const disabledHookScript = `const fs = require('fs');
|
||||
console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook should not execute", reason: "This hook is disabled"}));`;
|
||||
|
||||
const activePath = join(rig.testDir!, 'active_hook.sh');
|
||||
const disabledPath = join(rig.testDir!, 'disabled_hook.sh');
|
||||
const activePath = join(rig.testDir!, 'active_hook.cjs');
|
||||
const disabledPath = join(rig.testDir!, 'disabled_hook.cjs');
|
||||
|
||||
writeFileSync(activePath, activeHookScript);
|
||||
writeFileSync(disabledPath, disabledHookScript);
|
||||
const { execSync } = await import('node:child_process');
|
||||
execSync(`chmod +x "${activePath}"`);
|
||||
execSync(`chmod +x "${disabledPath}"`);
|
||||
|
||||
await rig.setup(
|
||||
'should respect disabled hooks across multiple operations',
|
||||
{
|
||||
settings: {
|
||||
tools: {
|
||||
enableHooks: true,
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: activePath,
|
||||
command: `node "${activePath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
type: 'command',
|
||||
command: disabledPath,
|
||||
command: `node "${disabledPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
disabled: [disabledPath], // Disable the second hook
|
||||
disabled: [`node "${disabledPath}"`], // Disable the second hook
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1441,10 +1554,10 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
|
||||
// Check hook telemetry
|
||||
const hookLogs1 = rig.readHookLogs();
|
||||
const activeHookLog1 = hookLogs1.find(
|
||||
(log) => log.hookCall.hook_name === activePath,
|
||||
(log) => log.hookCall.hook_name === `node "${activePath}"`,
|
||||
);
|
||||
const disabledHookLog1 = hookLogs1.find(
|
||||
(log) => log.hookCall.hook_name === disabledPath,
|
||||
(log) => log.hookCall.hook_name === `node "${disabledPath}"`,
|
||||
);
|
||||
|
||||
expect(activeHookLog1).toBeDefined();
|
||||
@@ -1465,9 +1578,171 @@ echo '{"decision": "block", "systemMessage": "Disabled hook should not execute",
|
||||
// Verify disabled hook still hasn't executed
|
||||
const hookLogs2 = rig.readHookLogs();
|
||||
const disabledHookCalls = hookLogs2.filter(
|
||||
(log) => log.hookCall.hook_name === disabledPath,
|
||||
(log) => log.hookCall.hook_name === `node "${disabledPath}"`,
|
||||
);
|
||||
expect(disabledHookCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeTool Hooks - Input Override', () => {
|
||||
it('should override tool input parameters via BeforeTool hook', async () => {
|
||||
// 1. First setup to get the test directory and prepare the hook script
|
||||
await rig.setup(
|
||||
'should override tool input parameters via BeforeTool hook',
|
||||
);
|
||||
|
||||
// Create a hook script that overrides the tool input
|
||||
const hookOutput = {
|
||||
decision: 'allow',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeTool',
|
||||
tool_input: {
|
||||
file_path: 'modified.txt',
|
||||
content: 'modified content',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const hookScript = `process.stdout.write(JSON.stringify(${JSON.stringify(
|
||||
hookOutput,
|
||||
)}));`;
|
||||
|
||||
const scriptPath = join(rig.testDir!, 'input_override_hook.js');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
// Ensure path is properly escaped for command line usage on all platforms
|
||||
// On Windows, backslashes in the command string need to be handled carefully
|
||||
// Using forward slashes works well with Node.js on all platforms
|
||||
const commandPath = scriptPath.replace(/\\/g, '/');
|
||||
|
||||
// 2. Full setup with settings and fake responses
|
||||
await rig.setup(
|
||||
'should override tool input parameters via BeforeTool hook',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.input-modification.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${commandPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Run the agent. The fake response will attempt to call write_file with
|
||||
// file_path="original.txt" and content="original content"
|
||||
await rig.run({
|
||||
args: 'Create a file called original.txt with content "original content"',
|
||||
});
|
||||
|
||||
// 1. Verify that 'modified.txt' was created with 'modified content' (Override successful)
|
||||
const modifiedContent = rig.readFile('modified.txt');
|
||||
expect(modifiedContent).toBe('modified content');
|
||||
|
||||
// 2. Verify that 'original.txt' was NOT created (Override replaced original)
|
||||
let originalExists = false;
|
||||
try {
|
||||
rig.readFile('original.txt');
|
||||
originalExists = true;
|
||||
} catch {
|
||||
originalExists = false;
|
||||
}
|
||||
expect(originalExists).toBe(false);
|
||||
|
||||
// 3. Verify hook telemetry
|
||||
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
|
||||
expect(hookTelemetryFound).toBeTruthy();
|
||||
|
||||
const hookLogs = rig.readHookLogs();
|
||||
expect(hookLogs.length).toBe(1);
|
||||
expect(hookLogs[0].hookCall.hook_name).toContain(
|
||||
'input_override_hook.js',
|
||||
);
|
||||
|
||||
// 4. Verify that the agent didn't try to work-around the hook input change
|
||||
const toolLogs = rig.readToolLogs();
|
||||
expect(toolLogs.length).toBe(1);
|
||||
expect(toolLogs[0].toolRequest.name).toBe('write_file');
|
||||
expect(JSON.parse(toolLogs[0].toolRequest.args).file_path).toBe(
|
||||
'modified.txt',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeTool Hooks - Stop Execution', () => {
|
||||
it('should stop agent execution via BeforeTool hook', async () => {
|
||||
// Create a hook script that stops execution
|
||||
const hookOutput = {
|
||||
continue: false,
|
||||
reason: 'Emergency Stop triggered by hook',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeTool',
|
||||
},
|
||||
};
|
||||
|
||||
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
|
||||
hookOutput,
|
||||
)}));`;
|
||||
|
||||
await rig.setup('should stop agent execution via BeforeTool hook');
|
||||
const scriptPath = join(rig.testDir!, 'before_tool_stop_hook.js');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
const commandPath = scriptPath.replace(/\\/g, '/');
|
||||
|
||||
await rig.setup('should stop agent execution via BeforeTool hook', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-tool-stop.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${commandPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await rig.run({
|
||||
args: 'Use write_file to create test.txt',
|
||||
});
|
||||
|
||||
// The hook should have stopped execution message (returned from tool)
|
||||
expect(result).toContain(
|
||||
'Agent execution stopped: Emergency Stop triggered by hook',
|
||||
);
|
||||
|
||||
// Tool should NOT be called successfully (it was blocked/stopped)
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeFileCalls = toolLogs.filter(
|
||||
(t) =>
|
||||
t.toolRequest.name === 'write_file' && t.toolRequest.success === true,
|
||||
);
|
||||
expect(writeFileCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('mcp server with cyclic tool schema is detected', () => {
|
||||
const run = await rig.runInteractive();
|
||||
|
||||
await run.type('/mcp list');
|
||||
await run.sendKeys('\r');
|
||||
await run.type('\r');
|
||||
|
||||
await run.expectText('tool_with_cyclic_schema');
|
||||
});
|
||||
|
||||
@@ -27,6 +27,10 @@ class MockConfig {
|
||||
getDebugMode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringRespectGeminiIgnore() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ripgrep-real-direct', () => {
|
||||
|
||||
@@ -209,6 +209,12 @@ export class InteractiveRun {
|
||||
async type(text: string) {
|
||||
let typedSoFar = '';
|
||||
for (const char of text) {
|
||||
if (char === '\r') {
|
||||
// wait >30ms before `enter` to avoid fast return conversion
|
||||
// from bufferFastReturn() in KeypressContent.tsx
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
this.ptyProcess.write(char);
|
||||
typedSoFar += char;
|
||||
|
||||
@@ -285,7 +291,9 @@ export class TestRig {
|
||||
) {
|
||||
this.testName = testName;
|
||||
const sanitizedName = sanitizeTestName(testName);
|
||||
this.testDir = join(env['INTEGRATION_TEST_FILE_DIR']!, sanitizedName);
|
||||
const testFileDir =
|
||||
env['INTEGRATION_TEST_FILE_DIR'] || join(os.tmpdir(), 'gemini-cli-tests');
|
||||
this.testDir = join(testFileDir, sanitizedName);
|
||||
mkdirSync(this.testDir, { recursive: true });
|
||||
if (options.fakeResponsesPath) {
|
||||
this.fakeResponsesPath = join(this.testDir, 'fake-responses.json');
|
||||
@@ -323,7 +331,9 @@ export class TestRig {
|
||||
ui: {
|
||||
useAlternateBuffer: true,
|
||||
},
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
model: {
|
||||
name: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
sandbox:
|
||||
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
|
||||
// Don't show the IDE connection dialog when running from VsCode
|
||||
|
||||
Generated
+871
-58
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.24.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251213.977248e09"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -52,7 +52,7 @@
|
||||
"lint:all": "node scripts/lint.js",
|
||||
"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",
|
||||
"preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci",
|
||||
"prepare": "husky && npm run bundle",
|
||||
"prepare:package": "node scripts/prepare-package.js",
|
||||
"release:version": "node scripts/version.js",
|
||||
@@ -62,7 +62,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.6",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -89,12 +89,13 @@
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"depcheck": "^1.4.7",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-wasm": "^1.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-headers": "^1.3.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-license-header": "^0.8.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"glob": "^12.0.0",
|
||||
@@ -113,13 +114,14 @@
|
||||
"react-devtools-core": "^6.1.2",
|
||||
"semver": "^7.7.2",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"ts-prune": "^0.10.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.6",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"latest-version": "^9.0.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.24.0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,13 +25,13 @@
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.2",
|
||||
"@a2a-js/sdk": "^0.3.7",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"express": "^5.1.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"tar": "^7.5.2",
|
||||
"uuid": "^11.1.0",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -123,7 +123,7 @@ export class Task {
|
||||
// process. This is not scoped to the individual task but reflects the global connection
|
||||
// state managed within the @gemini-cli/core module.
|
||||
async getMetadata(): Promise<TaskMetadata> {
|
||||
const toolRegistry = await this.config.getToolRegistry();
|
||||
const toolRegistry = this.config.getToolRegistry();
|
||||
const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const serverStatuses = getAllMCPServerStatuses();
|
||||
const servers = Object.keys(mcpServers).map((serverName) => ({
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { ExtensionsCommand } from './extensions.js';
|
||||
import { InitCommand } from './init.js';
|
||||
import { RestoreCommand } from './restore.js';
|
||||
@@ -20,7 +21,7 @@ class CommandRegistry {
|
||||
|
||||
register(command: Command) {
|
||||
if (this.commands.has(command.name)) {
|
||||
console.warn(`Command ${command.name} already registered. Skipping.`);
|
||||
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const suffix = Math.random().toString(36).slice(2);
|
||||
@@ -75,7 +76,7 @@ describe('loadSettings', () => {
|
||||
fs.rmSync(mockWorkspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to cleanup temp dirs', e);
|
||||
debugLogger.error('Failed to cleanup temp dirs', e);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
createStreamMessageRequest,
|
||||
createMockConfig,
|
||||
} from '../utils/testing_utils.js';
|
||||
import { MockTool } from '@google/gemini-cli-core';
|
||||
// Import MockTool from specific path to avoid vitest dependency in main core bundle
|
||||
import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js';
|
||||
import type { Command, CommandContext } from '../commands/types.js';
|
||||
|
||||
const mockToolConfirmationFn = async () =>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
import { loadExtensions } from '../config/extension.js';
|
||||
import { commandRegistry } from '../commands/command-registry.js';
|
||||
import { SimpleExtensionLoader } from '@google/gemini-cli-core';
|
||||
import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core';
|
||||
import type { Command, CommandArgument } from '../commands/types.js';
|
||||
import { GitService } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -239,7 +239,7 @@ export async function createApp() {
|
||||
): CommandResponse | undefined => {
|
||||
const commandName = command.name;
|
||||
if (visited.includes(commandName)) {
|
||||
console.warn(
|
||||
debugLogger.warn(
|
||||
`Command ${commandName} already inserted in the response, skipping`,
|
||||
);
|
||||
return undefined;
|
||||
|
||||
@@ -59,7 +59,6 @@ export function createMockConfig(
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getUserTier: vi.fn(),
|
||||
getEnableMessageBusIntegration: vi.fn().mockReturnValue(false),
|
||||
getMessageBus: vi.fn(),
|
||||
getPolicyEngine: vi.fn(),
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251213.977248e09",
|
||||
"version": "0.24.0",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,9 +26,10 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251213.977248e09"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.24.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.11.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -44,7 +45,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.6",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -228,7 +228,9 @@ describe('extensions disable command', () => {
|
||||
_: [],
|
||||
$0: '',
|
||||
};
|
||||
await (command.handler as unknown as (args: TestArgv) => void)(argv);
|
||||
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
|
||||
argv,
|
||||
);
|
||||
expect(mockExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: '/test/dir',
|
||||
|
||||
@@ -205,7 +205,9 @@ describe('extensions enable command', () => {
|
||||
_: [],
|
||||
$0: '',
|
||||
};
|
||||
await (command.handler as unknown as (args: TestArgv) => void)(argv);
|
||||
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockExtensionManager.prototype.enableExtension,
|
||||
|
||||
@@ -4,11 +4,23 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, type MockInstance, type Mock } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { handleInstall, installCommand } from './install.js';
|
||||
import yargs from 'yargs';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import type { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import type {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import type * as fs from 'node:fs/promises';
|
||||
import type { Stats } from 'node:fs';
|
||||
@@ -20,12 +32,15 @@ const mockRequestConsentNonInteractive: Mock<
|
||||
typeof requestConsentNonInteractive
|
||||
> = vi.hoisted(() => vi.fn());
|
||||
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
|
||||
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
|
||||
() => vi.fn(),
|
||||
);
|
||||
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
||||
return {
|
||||
@@ -34,6 +49,7 @@ vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
|
||||
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
||||
loadExtensions: vi.fn(),
|
||||
})),
|
||||
inferInstallMetadata: mockInferInstallMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -72,12 +88,31 @@ describe('handleInstall', () => {
|
||||
processSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
mockInferInstallMetadata.mockImplementation(async (source, args) => {
|
||||
if (
|
||||
source.startsWith('http://') ||
|
||||
source.startsWith('https://') ||
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://')
|
||||
) {
|
||||
return {
|
||||
source,
|
||||
type: 'git',
|
||||
ref: args?.ref,
|
||||
autoUpdate: args?.autoUpdate,
|
||||
allowPreRelease: args?.allowPreRelease,
|
||||
};
|
||||
}
|
||||
return { source, type: 'local' };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockInstallOrUpdateExtension.mockClear();
|
||||
mockRequestConsentNonInteractive.mockClear();
|
||||
mockStat.mockClear();
|
||||
mockInferInstallMetadata.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -124,7 +159,9 @@ describe('handleInstall', () => {
|
||||
});
|
||||
|
||||
it('throws an error from an unknown source', async () => {
|
||||
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
||||
mockInferInstallMetadata.mockRejectedValue(
|
||||
new Error('Install source not found.'),
|
||||
);
|
||||
await handleInstall({
|
||||
source: 'test://google.com',
|
||||
});
|
||||
|
||||
@@ -5,17 +5,16 @@
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
debugLogger,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import {
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
requestConsentNonInteractive,
|
||||
} from '../../config/extensions/consent.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import {
|
||||
ExtensionManager,
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
@@ -30,37 +29,12 @@ interface InstallArgs {
|
||||
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
try {
|
||||
let installMetadata: ExtensionInstallMetadata;
|
||||
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,
|
||||
allowPreRelease: args.allowPreRelease,
|
||||
};
|
||||
} else {
|
||||
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 installMetadata = await inferInstallMetadata(source, {
|
||||
ref: args.ref,
|
||||
autoUpdate: args.autoUpdate,
|
||||
allowPreRelease: args.allowPreRelease,
|
||||
});
|
||||
|
||||
const requestConsent = args.consent
|
||||
? () => Promise.resolve(true)
|
||||
|
||||
@@ -180,7 +180,9 @@ describe('extensions link command', () => {
|
||||
_: [],
|
||||
$0: '',
|
||||
};
|
||||
await (command.handler as unknown as (args: TestArgv) => void)(argv);
|
||||
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockExtensionManager.prototype.installOrUpdateExtension,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { settingsCommand } from './settings.js';
|
||||
import yargs from 'yargs';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import type { getExtensionAndManager } from './utils.js';
|
||||
import type {
|
||||
updateSetting,
|
||||
getScopedEnvContents,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import {
|
||||
promptForSetting,
|
||||
ExtensionSettingScope,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import type { exitCli } from '../utils.js';
|
||||
import type { ExtensionManager } from '../../config/extension-manager.js';
|
||||
|
||||
const mockGetExtensionAndManager: Mock<typeof getExtensionAndManager> =
|
||||
vi.hoisted(() => vi.fn());
|
||||
const mockUpdateSetting: Mock<typeof updateSetting> = vi.hoisted(() => vi.fn());
|
||||
const mockGetScopedEnvContents: Mock<typeof getScopedEnvContents> = vi.hoisted(
|
||||
() => vi.fn(),
|
||||
);
|
||||
const mockExitCli: Mock<typeof exitCli> = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./utils.js', () => ({
|
||||
getExtensionAndManager: mockGetExtensionAndManager,
|
||||
}));
|
||||
|
||||
vi.mock('../../config/extensions/extensionSettings.js', () => ({
|
||||
updateSetting: mockUpdateSetting,
|
||||
promptForSetting: vi.fn(),
|
||||
ExtensionSettingScope: {
|
||||
USER: 'user',
|
||||
WORKSPACE: 'workspace',
|
||||
},
|
||||
getScopedEnvContents: mockGetScopedEnvContents,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: mockExitCli,
|
||||
}));
|
||||
|
||||
describe('settings command', () => {
|
||||
let debugLogSpy: Mock;
|
||||
let debugErrorSpy: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
debugLogSpy = debugLogger.log as Mock;
|
||||
debugErrorSpy = debugLogger.error as Mock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('set command', () => {
|
||||
it('should log error and exit if extension is not found', async () => {
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: null,
|
||||
extensionManager: null,
|
||||
});
|
||||
|
||||
await yargs([])
|
||||
.command(settingsCommand)
|
||||
.parseAsync('settings set foo bar');
|
||||
|
||||
expect(mockExitCli).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log error and exit if extension config is not found', async () => {
|
||||
const mockExtensionManager = {
|
||||
loadExtensionConfig: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as ExtensionManager;
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: { path: '/path/to/ext' } as unknown as GeminiCLIExtension,
|
||||
extensionManager: mockExtensionManager,
|
||||
});
|
||||
|
||||
await yargs([])
|
||||
.command(settingsCommand)
|
||||
.parseAsync('settings set foo bar');
|
||||
|
||||
expect(debugErrorSpy).toHaveBeenCalledWith(
|
||||
'Could not find configuration for extension "foo".',
|
||||
);
|
||||
expect(mockExitCli).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call updateSetting with correct arguments', async () => {
|
||||
const mockExtensionManager = {
|
||||
loadExtensionConfig: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as ExtensionManager;
|
||||
const extension = { path: '/path/to/ext', id: 'ext-id' };
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: extension as unknown as GeminiCLIExtension,
|
||||
extensionManager: mockExtensionManager,
|
||||
});
|
||||
|
||||
await yargs([])
|
||||
.command(settingsCommand)
|
||||
.parseAsync('settings set foo bar --scope workspace');
|
||||
|
||||
expect(mockUpdateSetting).toHaveBeenCalledWith(
|
||||
{},
|
||||
'ext-id',
|
||||
'bar',
|
||||
promptForSetting,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
);
|
||||
expect(mockExitCli).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('list command', () => {
|
||||
it('should log error and exit if extension is not found', async () => {
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: null,
|
||||
extensionManager: null,
|
||||
});
|
||||
|
||||
await yargs([]).command(settingsCommand).parseAsync('settings list foo');
|
||||
|
||||
expect(mockExitCli).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log message and exit if extension has no settings', async () => {
|
||||
const mockExtensionManager = {
|
||||
loadExtensionConfig: vi.fn().mockResolvedValue({ settings: [] }),
|
||||
} as unknown as ExtensionManager;
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: { path: '/path/to/ext' } as unknown as GeminiCLIExtension,
|
||||
extensionManager: mockExtensionManager,
|
||||
});
|
||||
|
||||
await yargs([]).command(settingsCommand).parseAsync('settings list foo');
|
||||
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "foo" has no settings to configure.',
|
||||
);
|
||||
expect(mockExitCli).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should list settings correctly', async () => {
|
||||
const mockExtensionManager = {
|
||||
loadExtensionConfig: vi.fn().mockResolvedValue({
|
||||
settings: [
|
||||
{
|
||||
name: 'Setting 1',
|
||||
envVar: 'SETTING_1',
|
||||
description: 'Desc 1',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
name: 'Setting 2',
|
||||
envVar: 'SETTING_2',
|
||||
description: 'Desc 2',
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
name: 'Setting 3',
|
||||
envVar: 'SETTING_3',
|
||||
description: 'Desc 3',
|
||||
sensitive: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as ExtensionManager;
|
||||
const extension = { path: '/path/to/ext', id: 'ext-id' };
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
extension: extension as unknown as GeminiCLIExtension,
|
||||
extensionManager: mockExtensionManager,
|
||||
});
|
||||
|
||||
mockGetScopedEnvContents.mockImplementation((_config, _id, scope) => {
|
||||
if (scope === ExtensionSettingScope.USER) {
|
||||
return Promise.resolve({
|
||||
SETTING_1: 'val1',
|
||||
SETTING_2: 'val2',
|
||||
});
|
||||
}
|
||||
if (scope === ExtensionSettingScope.WORKSPACE) {
|
||||
return Promise.resolve({
|
||||
SETTING_3: 'val3',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
await yargs([]).command(settingsCommand).parseAsync('settings list foo');
|
||||
|
||||
expect(debugLogSpy).toHaveBeenCalledWith('Settings for "foo":');
|
||||
// Setting 1 (User)
|
||||
expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 1 (SETTING_1)');
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 1');
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(' Value: val1 (user)');
|
||||
// Setting 2 (Sensitive)
|
||||
expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 2 (SETTING_2)');
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 2');
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||
' Value: [value stored in keychain] (user)',
|
||||
);
|
||||
// Setting 3 (Workspace)
|
||||
expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 3 (SETTING_3)');
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 3');
|
||||
expect(debugLogSpy).toHaveBeenCalledWith(' Value: val3 (workspace)');
|
||||
|
||||
expect(mockExitCli).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,15 +47,17 @@ const setCommand: CommandModule<object, SetArgs> = {
|
||||
const { name, setting, scope } = args;
|
||||
const { extension, extensionManager } = await getExtensionAndManager(name);
|
||||
if (!extension || !extensionManager) {
|
||||
await exitCli();
|
||||
return;
|
||||
}
|
||||
const extensionConfig = extensionManager.loadExtensionConfig(
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (!extensionConfig) {
|
||||
debugLogger.error(
|
||||
`Could not find configuration for extension "${name}".`,
|
||||
);
|
||||
await exitCli();
|
||||
return;
|
||||
}
|
||||
await updateSetting(
|
||||
@@ -87,9 +89,10 @@ const listCommand: CommandModule<object, ListArgs> = {
|
||||
const { name } = args;
|
||||
const { extension, extensionManager } = await getExtensionAndManager(name);
|
||||
if (!extension || !extensionManager) {
|
||||
await exitCli();
|
||||
return;
|
||||
}
|
||||
const extensionConfig = extensionManager.loadExtensionConfig(
|
||||
const extensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
@@ -98,6 +101,7 @@ const listCommand: CommandModule<object, ListArgs> = {
|
||||
extensionConfig.settings.length === 0
|
||||
) {
|
||||
debugLogger.log(`Extension "${name}" has no settings to configure.`);
|
||||
await exitCli();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,9 @@ describe('extensions uninstall command', () => {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const argv: TestArgv = { names: ['my-extension'], _: [], $0: '' };
|
||||
await (command.handler as unknown as (args: TestArgv) => void)(argv);
|
||||
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith(
|
||||
'my-extension',
|
||||
|
||||
@@ -5,7 +5,15 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { describe, it, expect, vi, type MockInstance } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { handleValidate, validateCommand } from './validate.js';
|
||||
import yargs from 'yargs';
|
||||
import { createExtension } from '../../test-utils/createExtension.js';
|
||||
|
||||
@@ -41,7 +41,7 @@ async function validateExtension(args: ValidateArgs) {
|
||||
});
|
||||
const absoluteInputPath = path.resolve(args.path);
|
||||
const extensionConfig: ExtensionConfig =
|
||||
extensionManager.loadExtensionConfig(absoluteInputPath);
|
||||
await extensionManager.loadExtensionConfig(absoluteInputPath);
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
@@ -512,7 +512,7 @@ describe('migrate command', () => {
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'Note: Set tools.enableHooks to true in your settings to enable the hook system.',
|
||||
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,7 +243,7 @@ export async function handleMigrateFromClaude() {
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
debugLogger.log(
|
||||
'Note: Set tools.enableHooks to true in your settings to enable the hook system.',
|
||||
'Note: Set hooks.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, type Mock, type MockInstance } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type Mock,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
import { addCommand } from './add.js';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
@@ -59,10 +59,23 @@ async function testMCPConnection(
|
||||
version: '0.0.1',
|
||||
});
|
||||
|
||||
const settings = loadSettings();
|
||||
const sanitizationConfig = {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables:
|
||||
settings.merged.advanced?.excludedEnvVars || [],
|
||||
};
|
||||
|
||||
let transport;
|
||||
try {
|
||||
// Use the same transport creation logic as core
|
||||
transport = await createTransport(serverName, config, false);
|
||||
transport = await createTransport(
|
||||
serverName,
|
||||
config,
|
||||
false,
|
||||
sanitizationConfig,
|
||||
);
|
||||
} catch (_error) {
|
||||
await client.close();
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { skillsCommand } from './skills.js';
|
||||
|
||||
vi.mock('./skills/list.js', () => ({ listCommand: { command: 'list' } }));
|
||||
vi.mock('./skills/enable.js', () => ({
|
||||
enableCommand: { command: 'enable <name>' },
|
||||
}));
|
||||
vi.mock('./skills/disable.js', () => ({
|
||||
disableCommand: { command: 'disable <name>' },
|
||||
}));
|
||||
|
||||
vi.mock('../gemini.js', () => ({
|
||||
initializeOutputListenersAndFlush: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('skillsCommand', () => {
|
||||
it('should have correct command and aliases', () => {
|
||||
expect(skillsCommand.command).toBe('skills <command>');
|
||||
expect(skillsCommand.aliases).toEqual(['skill']);
|
||||
expect(skillsCommand.describe).toBe('Manage agent skills.');
|
||||
});
|
||||
|
||||
it('should register all subcommands in builder', () => {
|
||||
const mockYargs = {
|
||||
middleware: vi.fn().mockReturnThis(),
|
||||
command: vi.fn().mockReturnThis(),
|
||||
demandCommand: vi.fn().mockReturnThis(),
|
||||
version: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Mocking yargs
|
||||
skillsCommand.builder(mockYargs);
|
||||
|
||||
expect(mockYargs.middleware).toHaveBeenCalled();
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({
|
||||
command: 'enable <name>',
|
||||
});
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({
|
||||
command: 'disable <name>',
|
||||
});
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
|
||||
expect(mockYargs.version).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should have a handler that does nothing', () => {
|
||||
// @ts-expect-error - Handler doesn't take arguments in this case
|
||||
expect(skillsCommand.handler()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { listCommand } from './skills/list.js';
|
||||
import { enableCommand } from './skills/enable.js';
|
||||
import { disableCommand } from './skills/disable.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
|
||||
export const skillsCommand: CommandModule = {
|
||||
command: 'skills <command>',
|
||||
aliases: ['skill'],
|
||||
describe: 'Manage agent skills.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.middleware(() => initializeOutputListenersAndFlush())
|
||||
.command(listCommand)
|
||||
.command(enableCommand)
|
||||
.command(disableCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
import { handleDisable, disableCommand } from './disable.js';
|
||||
import {
|
||||
loadSettings,
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
type LoadableSettingScope,
|
||||
} from '../../config/settings.js';
|
||||
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
const debugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('log', format(message, ...args));
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('skills disable command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleDisable', () => {
|
||||
it('should disable an enabled skill in user scope', async () => {
|
||||
const mockSettings = {
|
||||
forScope: vi.fn().mockReturnValue({
|
||||
settings: { skills: { disabled: [] } },
|
||||
}),
|
||||
setValue: vi.fn(),
|
||||
};
|
||||
mockLoadSettings.mockReturnValue(
|
||||
mockSettings as unknown as LoadedSettings,
|
||||
);
|
||||
|
||||
await handleDisable({
|
||||
name: 'skill1',
|
||||
scope: SettingScope.User as LoadableSettingScope,
|
||||
});
|
||||
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'skills.disabled',
|
||||
['skill1'],
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Skill "skill1" successfully disabled in scope "User".',
|
||||
);
|
||||
});
|
||||
|
||||
it('should log a message if the skill is already disabled', async () => {
|
||||
const mockSettings = {
|
||||
forScope: vi.fn().mockReturnValue({
|
||||
settings: { skills: { disabled: ['skill1'] } },
|
||||
}),
|
||||
setValue: vi.fn(),
|
||||
};
|
||||
mockLoadSettings.mockReturnValue(
|
||||
mockSettings as unknown as LoadedSettings,
|
||||
);
|
||||
|
||||
await handleDisable({
|
||||
name: 'skill1',
|
||||
scope: SettingScope.User as LoadableSettingScope,
|
||||
});
|
||||
|
||||
expect(mockSettings.setValue).not.toHaveBeenCalled();
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Skill "skill1" is already disabled in scope "User".',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableCommand', () => {
|
||||
it('should have correct command and describe', () => {
|
||||
expect(disableCommand.command).toBe('disable <name>');
|
||||
expect(disableCommand.describe).toBe('Disables an agent skill.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
loadSettings,
|
||||
SettingScope,
|
||||
type LoadableSettingScope,
|
||||
} from '../../config/settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface DisableArgs {
|
||||
name: string;
|
||||
scope: LoadableSettingScope;
|
||||
}
|
||||
|
||||
export async function handleDisable(args: DisableArgs) {
|
||||
const { name, scope } = args;
|
||||
const workspaceDir = process.cwd();
|
||||
const settings = loadSettings(workspaceDir);
|
||||
|
||||
const currentDisabled =
|
||||
settings.forScope(scope).settings.skills?.disabled || [];
|
||||
|
||||
if (currentDisabled.includes(name)) {
|
||||
debugLogger.log(`Skill "${name}" is already disabled in scope "${scope}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newDisabled = [...currentDisabled, name];
|
||||
settings.setValue(scope, 'skills.disabled', newDisabled);
|
||||
debugLogger.log(`Skill "${name}" successfully disabled in scope "${scope}".`);
|
||||
}
|
||||
|
||||
export const disableCommand: CommandModule = {
|
||||
command: 'disable <name>',
|
||||
describe: 'Disables an agent skill.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name of the skill to disable.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
alias: 's',
|
||||
describe: 'The scope to disable the skill in (user or project).',
|
||||
type: 'string',
|
||||
default: 'user',
|
||||
choices: ['user', 'project'],
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const scope: LoadableSettingScope =
|
||||
argv['scope'] === 'project' ? SettingScope.Workspace : SettingScope.User;
|
||||
await handleDisable({
|
||||
name: argv['name'] as string,
|
||||
scope,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
import { handleEnable, enableCommand } from './enable.js';
|
||||
import {
|
||||
loadSettings,
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
type LoadableSettingScope,
|
||||
} from '../../config/settings.js';
|
||||
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
const debugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('log', format(message, ...args));
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('skills enable command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleEnable', () => {
|
||||
it('should enable a disabled skill in user scope', async () => {
|
||||
const mockSettings = {
|
||||
forScope: vi.fn().mockReturnValue({
|
||||
settings: { skills: { disabled: ['skill1'] } },
|
||||
}),
|
||||
setValue: vi.fn(),
|
||||
};
|
||||
mockLoadSettings.mockReturnValue(
|
||||
mockSettings as unknown as LoadedSettings,
|
||||
);
|
||||
|
||||
await handleEnable({
|
||||
name: 'skill1',
|
||||
scope: SettingScope.User as LoadableSettingScope,
|
||||
});
|
||||
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'skills.disabled',
|
||||
[],
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Skill "skill1" successfully enabled in scope "User".',
|
||||
);
|
||||
});
|
||||
|
||||
it('should log a message if the skill is already enabled', async () => {
|
||||
const mockSettings = {
|
||||
forScope: vi.fn().mockReturnValue({
|
||||
settings: { skills: { disabled: [] } },
|
||||
}),
|
||||
setValue: vi.fn(),
|
||||
};
|
||||
mockLoadSettings.mockReturnValue(
|
||||
mockSettings as unknown as LoadedSettings,
|
||||
);
|
||||
|
||||
await handleEnable({
|
||||
name: 'skill1',
|
||||
scope: SettingScope.User as LoadableSettingScope,
|
||||
});
|
||||
|
||||
expect(mockSettings.setValue).not.toHaveBeenCalled();
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'Skill "skill1" is already enabled in scope "User".',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableCommand', () => {
|
||||
it('should have correct command and describe', () => {
|
||||
expect(enableCommand.command).toBe('enable <name>');
|
||||
expect(enableCommand.describe).toBe('Enables an agent skill.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
loadSettings,
|
||||
SettingScope,
|
||||
type LoadableSettingScope,
|
||||
} from '../../config/settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface EnableArgs {
|
||||
name: string;
|
||||
scope: LoadableSettingScope;
|
||||
}
|
||||
|
||||
export async function handleEnable(args: EnableArgs) {
|
||||
const { name, scope } = args;
|
||||
const workspaceDir = process.cwd();
|
||||
const settings = loadSettings(workspaceDir);
|
||||
|
||||
const currentDisabled =
|
||||
settings.forScope(scope).settings.skills?.disabled || [];
|
||||
const newDisabled = currentDisabled.filter((d) => d !== name);
|
||||
|
||||
if (currentDisabled.length === newDisabled.length) {
|
||||
debugLogger.log(`Skill "${name}" is already enabled in scope "${scope}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.setValue(scope, 'skills.disabled', newDisabled);
|
||||
debugLogger.log(`Skill "${name}" successfully enabled in scope "${scope}".`);
|
||||
}
|
||||
|
||||
export const enableCommand: CommandModule = {
|
||||
command: 'enable <name>',
|
||||
describe: 'Enables an agent skill.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'The name of the skill to enable.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('scope', {
|
||||
alias: 's',
|
||||
describe: 'The scope to enable the skill in (user or project).',
|
||||
type: 'string',
|
||||
default: 'user',
|
||||
choices: ['user', 'project'],
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const scope: LoadableSettingScope =
|
||||
argv['scope'] === 'project' ? SettingScope.Workspace : SettingScope.User;
|
||||
await handleEnable({
|
||||
name: argv['name'] as string,
|
||||
scope,
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
import { handleList, listCommand } from './list.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { loadCliConfig } from '../../config/config.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const emitConsoleLog = vi.hoisted(() => vi.fn());
|
||||
const debugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('log', format(message, ...args));
|
||||
}),
|
||||
error: vi.fn((message, ...args) => {
|
||||
emitConsoleLog('error', format(message, ...args));
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
emitConsoleLog,
|
||||
},
|
||||
debugLogger,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../config/config.js');
|
||||
vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('skills list command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockLoadCliConfig = vi.mocked(loadCliConfig);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadSettings.mockReturnValue({
|
||||
merged: {},
|
||||
} as unknown as LoadedSettings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleList', () => {
|
||||
it('should log a message if no skills are discovered', async () => {
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
getAllSkills: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
await handleList();
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'No skills discovered.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should list all discovered skills', async () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'skill1',
|
||||
description: 'desc1',
|
||||
disabled: false,
|
||||
location: '/path/to/skill1',
|
||||
},
|
||||
{
|
||||
name: 'skill2',
|
||||
description: 'desc2',
|
||||
disabled: true,
|
||||
location: '/path/to/skill2',
|
||||
},
|
||||
];
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
getAllSkills: vi.fn().mockReturnValue(skills),
|
||||
}),
|
||||
};
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
await handleList();
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
chalk.bold('Discovered Agent Skills:'),
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('skill1'),
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining(chalk.green('[Enabled]')),
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('skill2'),
|
||||
);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining(chalk.red('[Disabled]')),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when listing fails', async () => {
|
||||
mockLoadCliConfig.mockRejectedValue(new Error('List failed'));
|
||||
|
||||
await expect(handleList()).rejects.toThrow('List failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCommand', () => {
|
||||
const command = listCommand;
|
||||
|
||||
it('should have correct command and describe', () => {
|
||||
expect(command.command).toBe('list');
|
||||
expect(command.describe).toBe('Lists discovered agent skills.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../../config/config.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export async function handleList() {
|
||||
const workspaceDir = process.cwd();
|
||||
const settings = loadSettings(workspaceDir);
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings.merged,
|
||||
'skills-list-session',
|
||||
{
|
||||
debug: false,
|
||||
} as Partial<CliArgs> as CliArgs,
|
||||
{ cwd: workspaceDir },
|
||||
);
|
||||
|
||||
// Initialize to trigger extension loading and skill discovery
|
||||
await config.initialize();
|
||||
|
||||
const skillManager = config.getSkillManager();
|
||||
const skills = skillManager.getAllSkills();
|
||||
|
||||
if (skills.length === 0) {
|
||||
debugLogger.log('No skills discovered.');
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.log(chalk.bold('Discovered Agent Skills:'));
|
||||
debugLogger.log('');
|
||||
|
||||
for (const skill of skills) {
|
||||
const status = skill.disabled
|
||||
? chalk.red('[Disabled]')
|
||||
: chalk.green('[Enabled]');
|
||||
|
||||
debugLogger.log(`${chalk.bold(skill.name)} ${status}`);
|
||||
debugLogger.log(` Description: ${skill.description}`);
|
||||
debugLogger.log(` Location: ${skill.location}`);
|
||||
debugLogger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule = {
|
||||
command: 'list',
|
||||
describe: 'Lists discovered agent skills.',
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
await handleList();
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
type ExtensionLoader,
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import type { Settings } from './settings.js';
|
||||
@@ -25,13 +26,11 @@ import { ExtensionManager } from './extension-manager.js';
|
||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
||||
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
|
||||
isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true, source: 'file' })), // Default to trusted
|
||||
}));
|
||||
|
||||
vi.mock('./sandboxConfig.js', () => ({
|
||||
loadSandboxConfig: vi.fn().mockResolvedValue(undefined),
|
||||
loadSandboxConfig: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
@@ -125,6 +124,12 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
createPolicyEngineConfig: vi.fn(async () => ({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
|
||||
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -631,14 +636,7 @@ describe('loadCliConfig', () => {
|
||||
expect(config.getFileFilteringRespectGeminiIgnore()).toBe(
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore,
|
||||
);
|
||||
});
|
||||
|
||||
it('should default enableMessageBusIntegration to true when unconfigured', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config['enableMessageBusIntegration']).toBe(true);
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1071,7 +1069,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
};
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode when it is disabled by settings',
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1283,7 +1281,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('auto');
|
||||
expect(config.getModel()).toBe('auto-gemini-2.5');
|
||||
});
|
||||
|
||||
it('always prefers model from argv', async () => {
|
||||
@@ -2325,3 +2323,223 @@ describe('Telemetry configuration via environment variables', () => {
|
||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PolicyEngine nonInteractive wiring', () => {
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should set nonInteractive to true in one-shot mode', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig({}, 'test-session', argv);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(
|
||||
(config.getPolicyEngine() as unknown as { nonInteractive: boolean })
|
||||
.nonInteractive,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should set nonInteractive to false in interactive mode', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig({}, 'test-session', argv);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(
|
||||
(config.getPolicyEngine() as unknown as { nonInteractive: boolean })
|
||||
.nonInteractive,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Policy Engine Integration in loadCliConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => {
|
||||
process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool'];
|
||||
const settings: Settings = { tools: { allowed: ['settings-tool'] } };
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
allowed: expect.arrayContaining(['cli-tool']),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => {
|
||||
process.stdin.isTTY = false; // Non-interactive to trigger default excludes
|
||||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const settings: Settings = { tools: { exclude: ['settings-exclude'] } };
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
// In non-interactive mode, ShellTool, etc. are excluded
|
||||
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
exclude: expect.arrayContaining([SHELL_TOOL_NAME]),
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig secureModeEnabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should throw an error if YOLO mode is attempted when secureModeEnabled is true', async () => {
|
||||
process.argv = ['node', 'script.js', '--yolo'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
admin: {
|
||||
secureModeEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if approval-mode=yolo is attempted when secureModeEnabled is true', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode=yolo'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
admin: {
|
||||
secureModeEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
);
|
||||
});
|
||||
|
||||
it('should set disableYoloMode to true when secureModeEnabled is true', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
admin: {
|
||||
secureModeEnabled: true,
|
||||
},
|
||||
};
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.isYoloModeDisabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig mcpEnabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mcpSettings = {
|
||||
mcp: {
|
||||
serverCommand: 'mcp-server',
|
||||
allowed: ['serverA'],
|
||||
excluded: ['serverB'],
|
||||
},
|
||||
mcpServers: { serverA: { url: 'http://a' } },
|
||||
};
|
||||
|
||||
it('should enable MCP by default', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = { ...mcpSettings };
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getMcpEnabled()).toBe(true);
|
||||
expect(config.getMcpServerCommand()).toBe('mcp-server');
|
||||
expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } });
|
||||
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
|
||||
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
|
||||
});
|
||||
|
||||
it('should disable MCP when mcpEnabled is false', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
...mcpSettings,
|
||||
admin: {
|
||||
mcp: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getMcpEnabled()).toBe(false);
|
||||
expect(config.getMcpServerCommand()).toBeUndefined();
|
||||
expect(config.getMcpServers()).toEqual({});
|
||||
expect(config.getAllowedMcpServers()).toEqual([]);
|
||||
expect(config.getBlockedMcpServers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should enable MCP when mcpEnabled is true', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
...mcpSettings,
|
||||
admin: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getMcpEnabled()).toBe(true);
|
||||
expect(config.getMcpServerCommand()).toBe('mcp-server');
|
||||
expect(config.getMcpServers()).toEqual({ serverA: { url: 'http://a' } });
|
||||
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
|
||||
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@ import yargs from 'yargs/yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import type { OutputFormat } from '@google/gemini-cli-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { skillsCommand } from '../commands/skills.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import {
|
||||
Config,
|
||||
@@ -32,8 +32,13 @@ import {
|
||||
loadServerHierarchicalMemory,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
getVersion,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import { saveModelChange, loadSettings } from './settings.js';
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
@@ -48,6 +53,7 @@ import { requestConsentNonInteractive } from './extensions/consent.js';
|
||||
import { promptForSetting } from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import { getEnableHooks } from './settingsSchema.js';
|
||||
|
||||
export interface CliArgs {
|
||||
query: string | undefined;
|
||||
@@ -69,7 +75,6 @@ export interface CliArgs {
|
||||
deleteSession: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
screenReader: boolean | undefined;
|
||||
useSmartEdit: boolean | undefined;
|
||||
useWriteTodos: boolean | undefined;
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
@@ -282,8 +287,12 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
yargsInstance.command(extensionsCommand);
|
||||
}
|
||||
|
||||
if (settings?.experimental?.skills ?? false) {
|
||||
yargsInstance.command(skillsCommand);
|
||||
}
|
||||
|
||||
// Register hooks command if hooks are enabled
|
||||
if (settings?.tools?.enableHooks) {
|
||||
if (getEnableHooks(settings)) {
|
||||
yargsInstance.command(hooksCommand);
|
||||
}
|
||||
|
||||
@@ -378,14 +387,24 @@ export function isDebugMode(argv: CliArgs): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadCliConfigOptions {
|
||||
cwd?: string;
|
||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
|
||||
disabled?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
settings: Settings,
|
||||
sessionId: string,
|
||||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const loadedSettings = loadSettings(cwd);
|
||||
|
||||
if (argv.sandbox) {
|
||||
process.env['GEMINI_SANDBOX'] = 'true';
|
||||
}
|
||||
@@ -395,7 +414,7 @@ export async function loadCliConfig(
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true;
|
||||
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? false;
|
||||
|
||||
// Set the context filename in the server's memoryTool module BEFORE loading memory
|
||||
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
|
||||
@@ -434,9 +453,15 @@ export async function loadCliConfig(
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const { memoryContent, fileCount, filePaths } =
|
||||
await loadServerHierarchicalMemory(
|
||||
const experimentalJitContext = settings.experimental?.jitContext ?? false;
|
||||
|
||||
let memoryContent = '';
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
|
||||
if (!experimentalJitContext) {
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
[],
|
||||
debugMode,
|
||||
@@ -447,6 +472,10 @@ export async function loadCliConfig(
|
||||
memoryFileFiltering,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
);
|
||||
memoryContent = result.memoryContent;
|
||||
fileCount = result.fileCount;
|
||||
filePaths = result.filePaths;
|
||||
}
|
||||
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
|
||||
@@ -476,11 +505,19 @@ export async function loadCliConfig(
|
||||
}
|
||||
|
||||
// Override approval mode if disableYoloMode is set.
|
||||
if (settings.security?.disableYoloMode) {
|
||||
if (settings.security?.disableYoloMode || settings.admin?.secureModeEnabled) {
|
||||
if (approvalMode === ApprovalMode.YOLO) {
|
||||
debugLogger.error('YOLO mode is disabled by the "disableYolo" setting.');
|
||||
if (settings.admin?.secureModeEnabled) {
|
||||
debugLogger.error(
|
||||
'YOLO mode is disabled by "secureModeEnabled" setting.',
|
||||
);
|
||||
} else {
|
||||
debugLogger.error(
|
||||
'YOLO mode is disabled by the "disableYolo" setting.',
|
||||
);
|
||||
}
|
||||
throw new FatalConfigError(
|
||||
'Cannot start in YOLO mode when it is disabled by settings',
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
);
|
||||
}
|
||||
approvalMode = ApprovalMode.DEFAULT;
|
||||
@@ -513,23 +550,16 @@ export async function loadCliConfig(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
settings,
|
||||
approvalMode,
|
||||
);
|
||||
|
||||
const enableMessageBusIntegration =
|
||||
settings.tools?.enableMessageBusIntegration ?? true;
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
|
||||
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
|
||||
const hasQuery = !!argv.query;
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.experimentalAcp ||
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive) {
|
||||
@@ -569,7 +599,29 @@ export async function loadCliConfig(
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
);
|
||||
|
||||
const defaultModel = DEFAULT_GEMINI_MODEL_AUTO;
|
||||
// Create a settings object that includes CLI overrides for policy generation
|
||||
const effectiveSettings: Settings = {
|
||||
...settings,
|
||||
tools: {
|
||||
...settings.tools,
|
||||
allowed: allowedTools,
|
||||
exclude: excludeTools,
|
||||
},
|
||||
mcp: {
|
||||
...settings.mcp,
|
||||
allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
|
||||
},
|
||||
};
|
||||
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
effectiveSettings,
|
||||
approvalMode,
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
const defaultModel = settings.general?.previewFeatures
|
||||
? PREVIEW_GEMINI_MODEL_AUTO
|
||||
: DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const resolvedModel: string =
|
||||
argv.model ||
|
||||
process.env['GEMINI_MODEL'] ||
|
||||
@@ -584,6 +636,8 @@ export async function loadCliConfig(
|
||||
|
||||
const ptyInfo = await getPty();
|
||||
|
||||
const mcpEnabled = settings.admin?.mcp?.enabled ?? true;
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
@@ -602,24 +656,34 @@ export async function loadCliConfig(
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: settings.mcp?.serverCommand,
|
||||
mcpServers: settings.mcpServers,
|
||||
allowedMcpServers: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
|
||||
blockedMcpServers: argv.allowedMcpServerNames
|
||||
? [] // explicitly allowed servers overrides everything
|
||||
: settings.mcp?.excluded,
|
||||
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
|
||||
mcpServers: mcpEnabled ? settings.mcpServers : {},
|
||||
mcpEnabled,
|
||||
allowedMcpServers: mcpEnabled
|
||||
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
|
||||
: undefined,
|
||||
blockedMcpServers: mcpEnabled
|
||||
? argv.allowedMcpServerNames
|
||||
? undefined
|
||||
: settings.mcp?.excluded
|
||||
: undefined,
|
||||
blockedEnvironmentVariables:
|
||||
settings.security?.environmentVariableRedaction?.blocked,
|
||||
enableEnvironmentVariableRedaction:
|
||||
settings.security?.environmentVariableRedaction?.enabled,
|
||||
userMemory: memoryContent,
|
||||
geminiMdFileCount: fileCount,
|
||||
geminiMdFilePaths: filePaths,
|
||||
approvalMode,
|
||||
disableYoloMode: settings.security?.disableYoloMode,
|
||||
disableYoloMode:
|
||||
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
|
||||
showMemoryUsage: settings.ui?.showMemoryUsage || false,
|
||||
accessibility: {
|
||||
...settings.ui?.accessibility,
|
||||
screenReader,
|
||||
},
|
||||
telemetry: telemetrySettings,
|
||||
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
|
||||
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled,
|
||||
fileFiltering,
|
||||
checkpointing: settings.general?.checkpointing?.enabled,
|
||||
proxy:
|
||||
@@ -631,7 +695,7 @@ export async function loadCliConfig(
|
||||
fileDiscoveryService: fileService,
|
||||
bugCommand: settings.advanced?.bugCommand,
|
||||
model: resolvedModel,
|
||||
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
|
||||
maxSessionTurns: settings.model?.maxSessionTurns,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
listSessions: argv.listSessions || false,
|
||||
@@ -640,8 +704,8 @@ export async function loadCliConfig(
|
||||
extensionLoader: extensionManager,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
enableModelAvailabilityService:
|
||||
settings.experimental?.isModelAvailabilityServiceEnabled,
|
||||
skillsSupport: settings.experimental?.skills,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
@@ -651,31 +715,40 @@ export async function loadCliConfig(
|
||||
interactive,
|
||||
trustedFolder,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell:
|
||||
settings.tools?.shell?.enableInteractiveShell ?? true,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
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,
|
||||
},
|
||||
enableMessageBusIntegration,
|
||||
codebaseInvestigatorSettings:
|
||||
settings.experimental?.codebaseInvestigatorSettings,
|
||||
introspectionAgentSettings:
|
||||
settings.experimental?.introspectionAgentSettings,
|
||||
fakeResponses: argv.fakeResponses,
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors ?? false,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
ptyInfo: ptyInfo?.name,
|
||||
modelConfigServiceConfig: settings.modelConfigs,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks: settings.tools?.enableHooks ?? false,
|
||||
enableHooks: getEnableHooks(settings),
|
||||
hooks: settings.hooks || {},
|
||||
projectHooks: projectHooks || {},
|
||||
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
||||
onReload: async () => {
|
||||
const refreshedSettings = loadSettings(cwd);
|
||||
return {
|
||||
disabledSkills: refreshedSettings.merged.skills?.disabled,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @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 path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { loadSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionManager skills validation', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let userExtensionsDir: string;
|
||||
let extensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-skills-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-skills-test-workspace-'),
|
||||
);
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
mockHomedir.mockReturnValue(tempHomeDir);
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn().mockResolvedValue(''),
|
||||
settings: loadSettings(tempWorkspaceDir).merged,
|
||||
});
|
||||
vi.spyOn(coreEvents, 'emitFeedback');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should emit a warning during install if skills directory is not empty but no skills are loaded', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'skills-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const skillsDir = path.join(sourceExtDir, 'skills');
|
||||
fs.mkdirSync(skillsDir);
|
||||
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
});
|
||||
|
||||
expect(extension.name).toBe('skills-ext');
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('Failed to load skills from'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit a warning during load if skills directory is not empty but no skills are loaded', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'load-skills-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const skillsDir = path.join(extDir, 'skills');
|
||||
fs.mkdirSync(skillsDir);
|
||||
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello');
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('Failed to load skills from'),
|
||||
);
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The directory is not empty but no valid skills were discovered',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed if skills are correctly loaded', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'good-skills-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const skillsDir = path.join(sourceExtDir, 'skills');
|
||||
const skillSubdir = path.join(skillsDir, 'test-skill');
|
||||
fs.mkdirSync(skillSubdir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillSubdir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test desc\n---\nbody',
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
});
|
||||
|
||||
expect(extension.skills).toHaveLength(1);
|
||||
expect(extension.skills![0].name).toBe('test-skill');
|
||||
// It might be called for other reasons during startup, but shouldn't be called for our skills loading success
|
||||
// Actually, it shouldn't be called with our warning message
|
||||
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('Failed to load skills from'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import chalk from 'chalk';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import { type Settings, SettingScope } from './settings.js';
|
||||
@@ -37,12 +38,15 @@ import {
|
||||
logExtensionInstallEvent,
|
||||
logExtensionUninstall,
|
||||
logExtensionUpdateEvent,
|
||||
loadSkillsFromDir,
|
||||
type ExtensionEvents,
|
||||
type MCPServerConfig,
|
||||
type ExtensionInstallMetadata,
|
||||
type GeminiCLIExtension,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type ResolvedExtensionSetting,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
@@ -56,9 +60,11 @@ import {
|
||||
import {
|
||||
getEnvContents,
|
||||
maybePromptForSettings,
|
||||
getMissingSettings,
|
||||
type ExtensionSetting,
|
||||
} from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
import { getEnableHooks } from './settingsSchema.js';
|
||||
|
||||
interface ExtensionManagerParams {
|
||||
enabledExtensionOverrides?: string[];
|
||||
@@ -198,7 +204,9 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
installMetadata.type === 'git') ||
|
||||
// Otherwise ask the user if they would like to try a git clone.
|
||||
(await this.requestConsent(
|
||||
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`,
|
||||
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.
|
||||
|
||||
Would you like to attempt to install via "git clone" instead?`,
|
||||
))
|
||||
) {
|
||||
await cloneFromGit(installMetadata, tempDir);
|
||||
@@ -220,26 +228,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
}
|
||||
|
||||
try {
|
||||
newExtensionConfig = this.loadExtensionConfig(localSourcePath);
|
||||
|
||||
if (isUpdate && installMetadata.autoUpdate) {
|
||||
const oldSettings = new Set(
|
||||
previousExtensionConfig.settings?.map((s) => s.name) || [],
|
||||
);
|
||||
const newSettings = new Set(
|
||||
newExtensionConfig.settings?.map((s) => s.name) || [],
|
||||
);
|
||||
|
||||
const settingsAreEqual =
|
||||
oldSettings.size === newSettings.size &&
|
||||
[...oldSettings].every((value) => newSettings.has(value));
|
||||
|
||||
if (!settingsAreEqual && installMetadata.autoUpdate) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
const previous = this.getExtensions().find(
|
||||
@@ -258,10 +247,17 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
const newHasHooks = fs.existsSync(
|
||||
path.join(localSourcePath, 'hooks', 'hooks.json'),
|
||||
);
|
||||
let previousHasHooks = false;
|
||||
if (isUpdate && previous && previous.hooks) {
|
||||
previousHasHooks = Object.keys(previous.hooks).length > 0;
|
||||
}
|
||||
const previousHasHooks = !!(
|
||||
isUpdate &&
|
||||
previous &&
|
||||
previous.hooks &&
|
||||
Object.keys(previous.hooks).length > 0
|
||||
);
|
||||
|
||||
const newSkills = await loadSkillsFromDir(
|
||||
path.join(localSourcePath, 'skills'),
|
||||
);
|
||||
const previousSkills = previous?.skills ?? [];
|
||||
|
||||
await maybeRequestConsentOrFail(
|
||||
newExtensionConfig,
|
||||
@@ -269,6 +265,8 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
newHasHooks,
|
||||
previousExtensionConfig,
|
||||
previousHasHooks,
|
||||
newSkills,
|
||||
previousSkills,
|
||||
);
|
||||
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
|
||||
const destinationPath = new ExtensionStorage(
|
||||
@@ -302,6 +300,20 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
}
|
||||
}
|
||||
|
||||
const missingSettings = await getMissingSettings(
|
||||
newExtensionConfig,
|
||||
extensionId,
|
||||
);
|
||||
if (missingSettings.length > 0) {
|
||||
const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings
|
||||
.map((s) => s.name)
|
||||
.join(
|
||||
', ',
|
||||
)}. Please run "gemini extensions settings ${newExtensionConfig.name} <setting-name>" to configure them.`;
|
||||
debugLogger.warn(message);
|
||||
coreEvents.emitFeedback('warning', message);
|
||||
}
|
||||
|
||||
if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'git' ||
|
||||
@@ -364,7 +376,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
// to get the name and version for logging.
|
||||
if (!newExtensionConfig && localSourcePath) {
|
||||
try {
|
||||
newExtensionConfig = this.loadExtensionConfig(localSourcePath);
|
||||
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
||||
} catch {
|
||||
// Ignore error, this is just for logging.
|
||||
}
|
||||
@@ -491,7 +503,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
}
|
||||
|
||||
try {
|
||||
let config = this.loadExtensionConfig(effectiveExtensionPath);
|
||||
let config = await this.loadExtensionConfig(effectiveExtensionPath);
|
||||
if (
|
||||
this.getExtensions().find((extension) => extension.name === config.name)
|
||||
) {
|
||||
@@ -506,6 +518,24 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
);
|
||||
config = resolveEnvVarsInObject(config, customEnv);
|
||||
|
||||
const resolvedSettings: ResolvedExtensionSetting[] = [];
|
||||
if (config.settings) {
|
||||
for (const setting of config.settings) {
|
||||
const value = customEnv[setting.envVar];
|
||||
resolvedSettings.push({
|
||||
name: setting.name,
|
||||
envVar: setting.envVar,
|
||||
value:
|
||||
value === undefined
|
||||
? '[not set]'
|
||||
: setting.sensitive
|
||||
? '***'
|
||||
: value,
|
||||
sensitive: setting.sensitive ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.mcpServers) {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
@@ -522,14 +552,18 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
if (this.settings.tools?.enableHooks) {
|
||||
if (getEnableHooks(this.settings)) {
|
||||
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
|
||||
extensionPath: effectiveExtensionPath,
|
||||
workspacePath: this.workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
const extension = {
|
||||
const skills = await loadSkillsFromDir(
|
||||
path.join(effectiveExtensionPath, 'skills'),
|
||||
);
|
||||
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
path: effectiveExtensionPath,
|
||||
@@ -543,6 +577,9 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
this.workspaceDir,
|
||||
),
|
||||
id: getExtensionId(config, installMetadata),
|
||||
settings: config.settings,
|
||||
resolvedSettings,
|
||||
skills,
|
||||
};
|
||||
this.loadedExtensions = [...this.loadedExtensions, extension];
|
||||
|
||||
@@ -571,13 +608,13 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
return this.maybeStopExtension(extension);
|
||||
}
|
||||
|
||||
loadExtensionConfig(extensionDir: string): ExtensionConfig {
|
||||
async loadExtensionConfig(extensionDir: string): Promise<ExtensionConfig> {
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
throw new Error(`Configuration file not found at ${configFilePath}`);
|
||||
}
|
||||
try {
|
||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
||||
const configContent = await fs.promises.readFile(configFilePath, 'utf-8');
|
||||
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
|
||||
if (!rawConfig.name || !rawConfig.version) {
|
||||
throw new Error(
|
||||
@@ -697,6 +734,19 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
output += `\n ${tool}`;
|
||||
});
|
||||
}
|
||||
if (extension.skills && extension.skills.length > 0) {
|
||||
output += `\n Agent skills:`;
|
||||
extension.skills.forEach((skill) => {
|
||||
output += `\n ${skill.name}: ${skill.description}`;
|
||||
});
|
||||
}
|
||||
const resolvedSettings = extension.resolvedSettings;
|
||||
if (resolvedSettings && resolvedSettings.length > 0) {
|
||||
output += `\n Settings:`;
|
||||
resolvedSettings.forEach((setting) => {
|
||||
output += `\n ${setting.name}: ${setting.value}`;
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -797,6 +847,45 @@ function validateName(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function inferInstallMetadata(
|
||||
source: string,
|
||||
args: {
|
||||
ref?: string;
|
||||
autoUpdate?: boolean;
|
||||
allowPreRelease?: boolean;
|
||||
} = {},
|
||||
): Promise<ExtensionInstallMetadata> {
|
||||
if (
|
||||
source.startsWith('http://') ||
|
||||
source.startsWith('https://') ||
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://')
|
||||
) {
|
||||
return {
|
||||
source,
|
||||
type: 'git',
|
||||
ref: args.ref,
|
||||
autoUpdate: args.autoUpdate,
|
||||
allowPreRelease: args.allowPreRelease,
|
||||
};
|
||||
} else {
|
||||
if (args.ref || args.autoUpdate) {
|
||||
throw new Error(
|
||||
'--ref and --auto-update are not applicable for local extensions.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await stat(source);
|
||||
return {
|
||||
source,
|
||||
type: 'local',
|
||||
};
|
||||
} catch {
|
||||
throw new Error('Install source not found.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getExtensionId(
|
||||
config: ExtensionConfig,
|
||||
installMetadata?: ExtensionInstallMetadata,
|
||||
|
||||
@@ -750,8 +750,8 @@ describe('extension tests', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
if (!settings.tools) settings.tools = {};
|
||||
settings.tools.enableHooks = true;
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
settings.hooks.enabled = true;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
@@ -771,7 +771,7 @@ describe('extension tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not load hooks if enableHooks is false', async () => {
|
||||
it('should not load hooks if hooks.enabled is false', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'hook-extension-disabled',
|
||||
@@ -786,8 +786,8 @@ describe('extension tests', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
if (!settings.tools) settings.tools = {};
|
||||
settings.tools.enableHooks = false;
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
settings.hooks.enabled = false;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
@@ -1424,9 +1424,10 @@ This extension will run the following MCP servers:
|
||||
],
|
||||
});
|
||||
|
||||
const previousExtensionConfig = extensionManager.loadExtensionConfig(
|
||||
path.join(userExtensionsDir, 'my-local-extension'),
|
||||
);
|
||||
const previousExtensionConfig =
|
||||
await extensionManager.loadExtensionConfig(
|
||||
path.join(userExtensionsDir, 'my-local-extension'),
|
||||
);
|
||||
mockPromptForSettings.mockResolvedValueOnce('new-setting-value');
|
||||
|
||||
// 3. Call installOrUpdateExtension to perform the update.
|
||||
@@ -1446,7 +1447,7 @@ This extension will run the following MCP servers:
|
||||
expect(envContent).toContain('NEW_SETTING=new-setting-value');
|
||||
});
|
||||
|
||||
it('should fail auto-update if settings have changed', async () => {
|
||||
it('should auto-update if settings have changed', async () => {
|
||||
// 1. Install initial version with autoUpdate: true
|
||||
const oldSourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
@@ -1468,7 +1469,7 @@ This extension will run the following MCP servers:
|
||||
});
|
||||
|
||||
// 2. Create new version with different settings
|
||||
const newSourceExtDir = createExtension({
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-auto-update-ext',
|
||||
version: '1.1.0',
|
||||
@@ -1481,19 +1482,23 @@ This extension will run the following MCP servers:
|
||||
],
|
||||
});
|
||||
|
||||
const previousExtensionConfig = extensionManager.loadExtensionConfig(
|
||||
path.join(userExtensionsDir, 'my-auto-update-ext'),
|
||||
);
|
||||
const previousExtensionConfig =
|
||||
await extensionManager.loadExtensionConfig(
|
||||
path.join(userExtensionsDir, 'my-auto-update-ext'),
|
||||
);
|
||||
|
||||
// 3. Attempt to update and assert it fails
|
||||
await expect(
|
||||
extensionManager.installOrUpdateExtension(
|
||||
{ source: newSourceExtDir, type: 'local', autoUpdate: true },
|
||||
previousExtensionConfig,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Extension "my-auto-update-ext" has settings changes and cannot be auto-updated. Please update manually.',
|
||||
const updatedExtension = await extensionManager.installOrUpdateExtension(
|
||||
{
|
||||
source: extensionDir,
|
||||
type: 'local',
|
||||
autoUpdate: true,
|
||||
},
|
||||
previousExtensionConfig,
|
||||
);
|
||||
|
||||
expect(updatedExtension.version).toBe('1.1.0');
|
||||
expect(extensionManager.getExtensions()[0].version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('should throw an error for invalid extension names', async () => {
|
||||
@@ -1967,7 +1972,7 @@ This extension will run the following MCP servers:
|
||||
expect(activeExtensions).toHaveLength(0);
|
||||
|
||||
await extensionManager.enableExtension('ext1', SettingScope.User);
|
||||
activeExtensions = await getActiveExtensions();
|
||||
activeExtensions = getActiveExtensions();
|
||||
expect(activeExtensions).toHaveLength(1);
|
||||
expect(activeExtensions[0].name).toBe('ext1');
|
||||
});
|
||||
@@ -1984,7 +1989,7 @@ This extension will run the following MCP servers:
|
||||
expect(activeExtensions).toHaveLength(0);
|
||||
|
||||
await extensionManager.enableExtension('ext1', SettingScope.Workspace);
|
||||
activeExtensions = await getActiveExtensions();
|
||||
activeExtensions = getActiveExtensions();
|
||||
expect(activeExtensions).toHaveLength(1);
|
||||
expect(activeExtensions[0].name).toBe('ext1');
|
||||
});
|
||||
|
||||
@@ -5,15 +5,20 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
requestConsentNonInteractive,
|
||||
requestConsentInteractive,
|
||||
maybeRequestConsentOrFail,
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
SKILLS_WARNING_MESSAGE,
|
||||
} from './consent.js';
|
||||
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
|
||||
|
||||
const mockReadline = vi.hoisted(() => ({
|
||||
createInterface: vi.fn().mockReturnValue({
|
||||
@@ -22,12 +27,26 @@ const mockReadline = vi.hoisted(() => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockReaddir = vi.hoisted(() => vi.fn());
|
||||
const originalReaddir = vi.hoisted(() => ({
|
||||
current: null as typeof fs.readdir | null,
|
||||
}));
|
||||
|
||||
// Mocking readline for non-interactive prompts
|
||||
vi.mock('node:readline', () => ({
|
||||
default: mockReadline,
|
||||
createInterface: mockReadline.createInterface,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
originalReaddir.current = actual.readdir;
|
||||
return {
|
||||
...actual,
|
||||
readdir: mockReaddir,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -40,11 +59,22 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe('consent', () => {
|
||||
beforeEach(() => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
if (originalReaddir.current) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockReaddir.mockImplementation(originalReaddir.current as any);
|
||||
}
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'consent-test-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('requestConsentNonInteractive', () => {
|
||||
@@ -250,6 +280,105 @@ describe('consent', () => {
|
||||
);
|
||||
expect(requestConsent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should request consent if skills change', async () => {
|
||||
const skill1Dir = path.join(tempDir, 'skill1');
|
||||
const skill2Dir = path.join(tempDir, 'skill2');
|
||||
await fs.mkdir(skill1Dir, { recursive: true });
|
||||
await fs.mkdir(skill2Dir, { recursive: true });
|
||||
await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1');
|
||||
await fs.writeFile(path.join(skill1Dir, 'extra.txt'), 'extra');
|
||||
await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'body2');
|
||||
|
||||
const skill1: SkillDefinition = {
|
||||
name: 'skill1',
|
||||
description: 'desc1',
|
||||
location: path.join(skill1Dir, 'SKILL.md'),
|
||||
body: 'body1',
|
||||
};
|
||||
const skill2: SkillDefinition = {
|
||||
name: 'skill2',
|
||||
description: 'desc2',
|
||||
location: path.join(skill2Dir, 'SKILL.md'),
|
||||
body: 'body2',
|
||||
};
|
||||
|
||||
const config: ExtensionConfig = {
|
||||
...baseConfig,
|
||||
mcpServers: {
|
||||
server1: { command: 'npm', args: ['start'] },
|
||||
server2: { httpUrl: 'https://remote.com' },
|
||||
},
|
||||
contextFileName: 'my-context.md',
|
||||
excludeTools: ['tool1', 'tool2'],
|
||||
};
|
||||
const requestConsent = vi.fn().mockResolvedValue(true);
|
||||
await maybeRequestConsentOrFail(
|
||||
config,
|
||||
requestConsent,
|
||||
false,
|
||||
undefined,
|
||||
false,
|
||||
[skill1, skill2],
|
||||
);
|
||||
|
||||
const expectedConsentString = [
|
||||
'Installing extension "test-ext".',
|
||||
INSTALL_WARNING_MESSAGE,
|
||||
'This extension will run the following MCP servers:',
|
||||
' * server1 (local): npm start',
|
||||
' * server2 (remote): https://remote.com',
|
||||
'This extension will append info to your gemini.md context using my-context.md',
|
||||
'This extension will exclude the following core tools: tool1,tool2',
|
||||
'',
|
||||
chalk.bold('Agent Skills:'),
|
||||
SKILLS_WARNING_MESSAGE,
|
||||
'This extension will install the following agent skills:',
|
||||
` * ${chalk.bold('skill1')}: desc1`,
|
||||
` (Location: ${skill1.location}) (2 items in directory)`,
|
||||
` * ${chalk.bold('skill2')}: desc2`,
|
||||
` (Location: ${skill2.location}) (1 items in directory)`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
|
||||
});
|
||||
|
||||
it('should show a warning if the skill directory cannot be read', async () => {
|
||||
const lockedDir = path.join(tempDir, 'locked');
|
||||
await fs.mkdir(lockedDir, { recursive: true });
|
||||
|
||||
const skill: SkillDefinition = {
|
||||
name: 'locked-skill',
|
||||
description: 'A skill in a locked dir',
|
||||
location: path.join(lockedDir, 'SKILL.md'),
|
||||
body: 'body',
|
||||
};
|
||||
|
||||
// Mock readdir to simulate a permission error.
|
||||
// We do this instead of using fs.mkdir(..., { mode: 0o000 }) because
|
||||
// directory permissions work differently on Windows and 0o000 doesn't
|
||||
// effectively block access there, leading to test failures in Windows CI.
|
||||
mockReaddir.mockRejectedValueOnce(
|
||||
new Error('EACCES: permission denied, scandir'),
|
||||
);
|
||||
|
||||
const requestConsent = vi.fn().mockResolvedValue(true);
|
||||
await maybeRequestConsentOrFail(
|
||||
baseConfig,
|
||||
requestConsent,
|
||||
false,
|
||||
undefined,
|
||||
false,
|
||||
[skill],
|
||||
);
|
||||
|
||||
expect(requestConsent).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
` (Location: ${skill.location}) ${chalk.red('⚠️ (Could not count items in directory)')}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||
import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
|
||||
export const INSTALL_WARNING_MESSAGE =
|
||||
'**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**';
|
||||
export const INSTALL_WARNING_MESSAGE = chalk.yellow(
|
||||
'The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.',
|
||||
);
|
||||
|
||||
export const SKILLS_WARNING_MESSAGE = chalk.yellow(
|
||||
"Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards.",
|
||||
);
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, by reading a Y/n
|
||||
@@ -38,7 +46,7 @@ export async function requestConsentNonInteractive(
|
||||
* 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.
|
||||
* @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
|
||||
* @returns boolean, whether they consented or not.
|
||||
*/
|
||||
export async function requestConsentInteractive(
|
||||
@@ -82,7 +90,7 @@ async function promptForConsentNonInteractive(
|
||||
* 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.
|
||||
* @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
|
||||
* @returns Whether or not the user answers yes.
|
||||
*/
|
||||
async function promptForConsentInteractive(
|
||||
@@ -103,10 +111,11 @@ async function promptForConsentInteractive(
|
||||
* Builds a consent string for installing an extension based on it's
|
||||
* extensionConfig.
|
||||
*/
|
||||
function extensionConsentString(
|
||||
async function extensionConsentString(
|
||||
extensionConfig: ExtensionConfig,
|
||||
hasHooks: boolean,
|
||||
): string {
|
||||
skills: SkillDefinition[] = [],
|
||||
): Promise<string> {
|
||||
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
|
||||
const output: string[] = [];
|
||||
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
|
||||
@@ -138,6 +147,24 @@ function extensionConsentString(
|
||||
'⚠️ This extension contains Hooks which can automatically execute commands.',
|
||||
);
|
||||
}
|
||||
if (skills.length > 0) {
|
||||
output.push(`\n${chalk.bold('Agent Skills:')}`);
|
||||
output.push(SKILLS_WARNING_MESSAGE);
|
||||
output.push('This extension will install the following agent skills:');
|
||||
for (const skill of skills) {
|
||||
output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`);
|
||||
const skillDir = path.dirname(skill.location);
|
||||
let fileCountStr = '';
|
||||
try {
|
||||
const skillDirItems = await fs.readdir(skillDir);
|
||||
fileCountStr = ` (${skillDirItems.length} items in directory)`;
|
||||
} catch {
|
||||
fileCountStr = ` ${chalk.red('⚠️ (Could not count items in directory)')}`;
|
||||
}
|
||||
output.push(` (Location: ${skill.location})${fileCountStr}`);
|
||||
}
|
||||
output.push('');
|
||||
}
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
@@ -156,12 +183,19 @@ export async function maybeRequestConsentOrFail(
|
||||
hasHooks: boolean,
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
previousHasHooks?: boolean,
|
||||
skills: SkillDefinition[] = [],
|
||||
previousSkills: SkillDefinition[] = [],
|
||||
) {
|
||||
const extensionConsent = extensionConsentString(extensionConfig, hasHooks);
|
||||
const extensionConsent = await extensionConsentString(
|
||||
extensionConfig,
|
||||
hasHooks,
|
||||
skills,
|
||||
);
|
||||
if (previousExtensionConfig) {
|
||||
const previousExtensionConsent = extensionConsentString(
|
||||
const previousExtensionConsent = await extensionConsentString(
|
||||
previousExtensionConfig,
|
||||
previousHasHooks ?? false,
|
||||
previousSkills,
|
||||
);
|
||||
if (previousExtensionConsent === extensionConsent) {
|
||||
return;
|
||||
|
||||
@@ -298,3 +298,24 @@ async function clearSettings(
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
export async function getMissingSettings(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
): Promise<ExtensionSetting[]> {
|
||||
const { settings } = extensionConfig;
|
||||
if (!settings || settings.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingSettings = await getEnvContents(extensionConfig, extensionId);
|
||||
const missingSettings: ExtensionSetting[] = [];
|
||||
|
||||
for (const setting of settings) {
|
||||
if (existingSettings[setting.envVar] === undefined) {
|
||||
missingSettings.push(setting);
|
||||
}
|
||||
}
|
||||
|
||||
return missingSettings;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs';
|
||||
import { getMissingSettings } from './extensionSettings.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
import {
|
||||
KeychainTokenStorage,
|
||||
debugLogger,
|
||||
type ExtensionInstallMetadata,
|
||||
type GeminiCLIExtension,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||
import { ExtensionManager } from '../extension-manager.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual.default,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
KeychainTokenStorage: vi.fn(),
|
||||
debugLogger: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
},
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(), // Mock emitFeedback
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock os.homedir because ExtensionStorage uses it
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('extensionUpdates', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let extensionDir: string;
|
||||
let mockKeychainData: Record<string, Record<string, string>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockKeychainData = {};
|
||||
|
||||
// Mock Keychain
|
||||
vi.mocked(KeychainTokenStorage).mockImplementation(
|
||||
(serviceName: string) => {
|
||||
if (!mockKeychainData[serviceName]) {
|
||||
mockKeychainData[serviceName] = {};
|
||||
}
|
||||
const keychainData = mockKeychainData[serviceName];
|
||||
return {
|
||||
getSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (key: string) => keychainData[key] || null,
|
||||
),
|
||||
setSecret: vi
|
||||
.fn()
|
||||
.mockImplementation(async (key: string, value: string) => {
|
||||
keychainData[key] = value;
|
||||
}),
|
||||
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
|
||||
delete keychainData[key];
|
||||
}),
|
||||
listSecrets: vi
|
||||
.fn()
|
||||
.mockImplementation(async () => Object.keys(keychainData)),
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
} as unknown as KeychainTokenStorage;
|
||||
},
|
||||
);
|
||||
|
||||
// Setup Temp Dirs
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
|
||||
|
||||
// Mock ExtensionStorage to rely on our temp extension dir
|
||||
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
|
||||
extensionDir,
|
||||
);
|
||||
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
|
||||
// getEnvFilePath in extensionSettings.ts:
|
||||
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
|
||||
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
|
||||
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getMissingSettings', () => {
|
||||
it('should return empty list if all settings are present', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
|
||||
],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
// Setup User Env
|
||||
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
|
||||
fs.writeFileSync(userEnvPath, 'VAR1=val1');
|
||||
|
||||
// Setup Keychain
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext ${extensionId}`,
|
||||
);
|
||||
await userKeychain.setSecret('VAR2', 'val2');
|
||||
|
||||
const missing = await getMissingSettings(config, extensionId);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should identify missing non-sensitive settings', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
const missing = await getMissingSettings(config, extensionId);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0].name).toBe('s1');
|
||||
});
|
||||
|
||||
it('should identify missing sensitive settings', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
|
||||
],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
const missing = await getMissingSettings(config, extensionId);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0].name).toBe('s2');
|
||||
});
|
||||
|
||||
it('should respect settings present in workspace', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
// Setup Workspace Env
|
||||
const workspaceEnvPath = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
);
|
||||
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
|
||||
|
||||
const missing = await getMissingSettings(config, extensionId);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExtensionManager integration', () => {
|
||||
it('should warn about missing settings after update', async () => {
|
||||
// Mock ExtensionManager methods to avoid FS/Network usage
|
||||
const newConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.1.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
|
||||
const previousConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [],
|
||||
};
|
||||
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: extensionDir,
|
||||
type: 'local',
|
||||
autoUpdate: true,
|
||||
};
|
||||
|
||||
const manager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
settings: { telemetry: {} } as any,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null, // Simulate non-interactive
|
||||
});
|
||||
|
||||
// Mock methods called by installOrUpdateExtension
|
||||
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
|
||||
vi.spyOn(manager, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata,
|
||||
path: extensionDir,
|
||||
// Mocks for other required props
|
||||
contextFiles: [],
|
||||
mcpServers: {},
|
||||
hooks: undefined,
|
||||
isActive: true,
|
||||
id: 'test-id',
|
||||
settings: [],
|
||||
resolvedSettings: [],
|
||||
skills: [],
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue(
|
||||
{} as unknown as GeminiCLIExtension,
|
||||
);
|
||||
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
|
||||
|
||||
// Mock fs.promises for the operations inside installOrUpdateExtension
|
||||
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
|
||||
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
|
||||
|
||||
// Mock copyExtension? It's imported.
|
||||
// We can rely on ignoring the failure if we mock enough.
|
||||
// Actually copyExtension is called. We need to mock it if it does real IO.
|
||||
// But we can just let it fail or mock fs.cp if it uses it.
|
||||
// Let's assume the other mocks cover the critical path to the warning.
|
||||
// Warning happens BEFORE copyExtension?
|
||||
// No, warning is after copyExtension usually.
|
||||
// But in my code:
|
||||
// const missingSettings = await getMissingSettings(...)
|
||||
// if (missingSettings.length > 0) debugLogger.warn(...)
|
||||
// ...
|
||||
// copyExtension(...)
|
||||
|
||||
// Wait, let's check extension-manager.ts order.
|
||||
// Line 303: getMissingSettings
|
||||
// Line 317: if (installMetadata.type === 'local' ...) copyExtension
|
||||
|
||||
// So getMissingSettings is called BEFORE copyExtension.
|
||||
|
||||
try {
|
||||
await manager.installOrUpdateExtension(installMetadata, previousConfig);
|
||||
} catch (_) {
|
||||
// Ignore errors from copyExtension or others, we just want to verify the warning
|
||||
}
|
||||
|
||||
expect(debugLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Extension "test-ext" has missing settings: s1',
|
||||
),
|
||||
);
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'Please run "gemini extensions settings test-ext <setting-name>"',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -221,9 +221,11 @@ describe('github.ts', () => {
|
||||
});
|
||||
|
||||
it('should return NOT_UPDATABLE for non-git/non-release extensions', async () => {
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
|
||||
version: '1.0.0',
|
||||
} as unknown as ExtensionConfig);
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
|
||||
Promise.resolve({
|
||||
version: '1.0.0',
|
||||
} as unknown as ExtensionConfig),
|
||||
);
|
||||
|
||||
const linkExt = {
|
||||
installMetadata: { type: 'link' },
|
||||
|
||||
@@ -175,7 +175,7 @@ export async function checkForExtensionUpdate(
|
||||
if (installMetadata?.type === 'local') {
|
||||
let latestConfig: ExtensionConfig | undefined;
|
||||
try {
|
||||
latestConfig = extensionManager.loadExtensionConfig(
|
||||
latestConfig = await extensionManager.loadExtensionConfig(
|
||||
installMetadata.source,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -145,10 +145,12 @@ describe('Extension Update Logic', () => {
|
||||
});
|
||||
|
||||
it('should successfully update extension and set state to UPDATED_NEEDS_RESTART by default', async () => {
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
|
||||
Promise.resolve({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
vi.mocked(
|
||||
mockExtensionManager.installOrUpdateExtension,
|
||||
).mockResolvedValue({
|
||||
@@ -183,10 +185,12 @@ describe('Extension Update Logic', () => {
|
||||
});
|
||||
|
||||
it('should set state to UPDATED if enableExtensionReloading is true', async () => {
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
|
||||
Promise.resolve({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
vi.mocked(
|
||||
mockExtensionManager.installOrUpdateExtension,
|
||||
).mockResolvedValue({
|
||||
@@ -212,10 +216,12 @@ describe('Extension Update Logic', () => {
|
||||
});
|
||||
|
||||
it('should rollback and set state to ERROR if installation fails', async () => {
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
|
||||
Promise.resolve({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
vi.mocked(
|
||||
mockExtensionManager.installOrUpdateExtension,
|
||||
).mockRejectedValue(new Error('Install failed'));
|
||||
@@ -257,10 +263,12 @@ describe('Extension Update Logic', () => {
|
||||
['ext3', { status: ExtensionUpdateState.UPDATE_AVAILABLE }],
|
||||
]);
|
||||
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
|
||||
name: 'ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
|
||||
Promise.resolve({
|
||||
name: 'ext',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
vi.mocked(
|
||||
mockExtensionManager.installOrUpdateExtension,
|
||||
).mockResolvedValue({ ...mockExtension, version: '1.1.0' });
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function updateExtension(
|
||||
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
const previousExtensionConfig = extensionManager.loadExtensionConfig(
|
||||
const previousExtensionConfig = await extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
let updatedExtension: GeminiCLIExtension;
|
||||
|
||||
@@ -192,7 +192,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'x', ctrl: true },
|
||||
{ sequence: '\x18', ctrl: true },
|
||||
],
|
||||
[Command.PASTE_CLIPBOARD]: [{ key: 'v', ctrl: true }],
|
||||
[Command.PASTE_CLIPBOARD]: [
|
||||
{ key: 'v', ctrl: true },
|
||||
{ key: 'v', command: true },
|
||||
],
|
||||
|
||||
// App level bindings
|
||||
[Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
ApprovalMode,
|
||||
PolicyDecision,
|
||||
@@ -13,6 +13,21 @@ import {
|
||||
import { createPolicyEngineConfig } from './policy.js';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
// Mock Storage to ensure tests are hermetic and don't read from user's home directory
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const Storage = actual.Storage;
|
||||
// Monkey-patch static methods
|
||||
Storage.getUserPoliciesDir = () => '/non-existent/user/policies';
|
||||
Storage.getSystemPoliciesDir = () => '/non-existent/system/policies';
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Storage,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Policy Engine Integration Tests', () => {
|
||||
describe('Policy configuration produces valid PolicyEngine config', () => {
|
||||
it('should create a working PolicyEngine from basic settings', async () => {
|
||||
|
||||
@@ -219,6 +219,60 @@ describe('settings-validation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate mcpServers with type field for all transport types', () => {
|
||||
const validSettings = {
|
||||
mcpServers: {
|
||||
'sse-server': {
|
||||
url: 'https://example.com/sse',
|
||||
type: 'sse',
|
||||
headers: { 'X-API-Key': 'key' },
|
||||
},
|
||||
'http-server': {
|
||||
url: 'https://example.com/mcp',
|
||||
type: 'http',
|
||||
},
|
||||
'stdio-server': {
|
||||
command: '/usr/bin/mcp-server',
|
||||
type: 'stdio',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(validSettings);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid type values in mcpServers', () => {
|
||||
const invalidSettings = {
|
||||
mcpServers: {
|
||||
'bad-server': {
|
||||
url: 'https://example.com/mcp',
|
||||
type: 'invalid-type',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(invalidSettings);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate mcpServers without type field', () => {
|
||||
const validSettings = {
|
||||
mcpServers: {
|
||||
'stdio-server': {
|
||||
command: '/usr/bin/mcp-server',
|
||||
args: ['--port', '8080'],
|
||||
},
|
||||
'url-server': {
|
||||
url: 'https://example.com/mcp',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(validSettings);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate complex nested customThemes configuration', () => {
|
||||
const invalidSettings = {
|
||||
ui: {
|
||||
@@ -269,9 +323,7 @@ describe('settings-validation', () => {
|
||||
expect(formatted).toContain('/path/to/settings.json');
|
||||
expect(formatted).toContain('model.name');
|
||||
expect(formatted).toContain('Expected: string, but received: object');
|
||||
expect(formatted).toContain(
|
||||
'Please fix the configuration and try again.',
|
||||
);
|
||||
expect(formatted).toContain('Please fix the configuration.');
|
||||
expect(formatted).toContain(
|
||||
'https://github.com/google-gemini/gemini-cli',
|
||||
);
|
||||
|
||||
@@ -322,7 +322,7 @@ export function formatValidationError(
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('Please fix the configuration and try again.');
|
||||
lines.push('Please fix the configuration.');
|
||||
lines.push(
|
||||
'See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md',
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ import { homedir, platform } from 'node:os';
|
||||
import * as dotenv from 'dotenv';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
debugLogger,
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
getErrorMessage,
|
||||
@@ -30,14 +29,12 @@ import {
|
||||
getSettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
||||
import { customDeepMerge } from '../utils/deepMerge.js';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import type { ExtensionManager } from './extension-manager.js';
|
||||
import {
|
||||
validateSettings,
|
||||
formatValidationError,
|
||||
} from './settings-validation.js';
|
||||
import { SettingPaths } from './settingPaths.js';
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
@@ -66,79 +63,6 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
||||
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
const MIGRATE_V2_OVERWRITE = true;
|
||||
|
||||
const MIGRATION_MAP: Record<string, string> = {
|
||||
accessibility: 'ui.accessibility',
|
||||
allowedTools: 'tools.allowed',
|
||||
allowMCPServers: 'mcp.allowed',
|
||||
autoAccept: 'tools.autoAccept',
|
||||
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
|
||||
bugCommand: 'advanced.bugCommand',
|
||||
chatCompression: 'model.compressionThreshold',
|
||||
checkpointing: 'general.checkpointing',
|
||||
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',
|
||||
enableHooks: 'tools.enableHooks',
|
||||
enablePromptCompletion: 'general.enablePromptCompletion',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
extensionManagement: 'experimental.extensionManagement',
|
||||
extensions: 'extensions',
|
||||
fileFiltering: 'context.fileFiltering',
|
||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
||||
folderTrust: 'security.folderTrust.enabled',
|
||||
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
|
||||
hideWindowTitle: 'ui.hideWindowTitle',
|
||||
showStatusInTitle: 'ui.showStatusInTitle',
|
||||
hideTips: 'ui.hideTips',
|
||||
hideBanner: 'ui.hideBanner',
|
||||
hideFooter: 'ui.hideFooter',
|
||||
hideCWD: 'ui.footer.hideCWD',
|
||||
hideSandboxStatus: 'ui.footer.hideSandboxStatus',
|
||||
hideModelInfo: 'ui.footer.hideModelInfo',
|
||||
hideContextSummary: 'ui.hideContextSummary',
|
||||
showMemoryUsage: 'ui.showMemoryUsage',
|
||||
showLineNumbers: 'ui.showLineNumbers',
|
||||
showCitations: 'ui.showCitations',
|
||||
ideMode: 'ide.enabled',
|
||||
includeDirectories: 'context.includeDirectories',
|
||||
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
|
||||
maxSessionTurns: 'model.maxSessionTurns',
|
||||
mcpServers: 'mcpServers',
|
||||
mcpServerCommand: 'mcp.serverCommand',
|
||||
memoryImportFormat: 'context.importFormat',
|
||||
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
|
||||
model: 'model.name',
|
||||
preferredEditor: SettingPaths.General.PreferredEditor,
|
||||
retryFetchErrors: 'general.retryFetchErrors',
|
||||
sandbox: 'tools.sandbox',
|
||||
selectedAuthType: 'security.auth.selectedType',
|
||||
enableInteractiveShell: 'tools.shell.enableInteractiveShell',
|
||||
shellPager: 'tools.shell.pager',
|
||||
shellShowColor: 'tools.shell.showColor',
|
||||
shellInactivityTimeout: 'tools.shell.inactivityTimeout',
|
||||
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
|
||||
summarizeToolOutput: 'model.summarizeToolOutput',
|
||||
telemetry: 'telemetry',
|
||||
theme: 'ui.theme',
|
||||
toolDiscoveryCommand: 'tools.discoveryCommand',
|
||||
toolCallCommand: 'tools.callCommand',
|
||||
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
|
||||
useExternalAuth: 'security.auth.useExternal',
|
||||
useRipgrep: 'tools.useRipgrep',
|
||||
vimMode: 'general.vimMode',
|
||||
};
|
||||
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
||||
@@ -233,6 +157,7 @@ export interface SessionRetentionSettings {
|
||||
export interface SettingsError {
|
||||
message: string;
|
||||
path: string;
|
||||
severity: 'error' | 'warning';
|
||||
}
|
||||
|
||||
export interface SettingsFile {
|
||||
@@ -267,160 +192,22 @@ function setNestedProperty(
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
export function needsMigration(settings: Record<string, unknown>): boolean {
|
||||
// A file needs migration if it contains any top-level key that is moved to a
|
||||
// nested location in V2.
|
||||
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
|
||||
if (v1Key === v2Path || !(v1Key in settings)) {
|
||||
return false;
|
||||
}
|
||||
// If a key exists that is a V1 key and a V2 container (like 'model'),
|
||||
// we need to check the type. If it's an object, it's a V2 container and not
|
||||
// a V1 key that needs migration.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(v1Key) &&
|
||||
typeof settings[v1Key] === 'object' &&
|
||||
settings[v1Key] !== null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return hasV1Keys;
|
||||
}
|
||||
|
||||
function migrateSettingsToV2(
|
||||
flatSettings: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
if (!needsMigration(flatSettings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const v2Settings: Record<string, unknown> = {};
|
||||
const flatKeys = new Set(Object.keys(flatSettings));
|
||||
|
||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||
if (flatKeys.has(oldKey)) {
|
||||
// If the key exists and is a V2 container (like 'model'), and the value is an object,
|
||||
// it is likely already migrated or partially migrated. We should not move it
|
||||
// to the mapped V2 path (e.g. 'model' -> 'model.name').
|
||||
// Instead, let it fall through to the "Carry over" section to be merged.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
typeof flatSettings[oldKey] === 'object' &&
|
||||
flatSettings[oldKey] !== null &&
|
||||
!Array.isArray(flatSettings[oldKey])
|
||||
) {
|
||||
continue;
|
||||
export function getDefaultsFromSchema(
|
||||
schema: SettingsSchema = getSettingsSchema(),
|
||||
): Settings {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const key in schema) {
|
||||
const definition = schema[key];
|
||||
if (definition.properties) {
|
||||
const childDefaults = getDefaultsFromSchema(definition.properties);
|
||||
if (Object.keys(childDefaults).length > 0) {
|
||||
defaults[key] = childDefaults;
|
||||
}
|
||||
|
||||
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
|
||||
flatKeys.delete(oldKey);
|
||||
} else if (definition.default !== undefined) {
|
||||
defaults[key] = definition.default;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve mcpServers at the top level
|
||||
if (flatSettings['mcpServers']) {
|
||||
v2Settings['mcpServers'] = flatSettings['mcpServers'];
|
||||
flatKeys.delete('mcpServers');
|
||||
}
|
||||
|
||||
// Carry over any unrecognized keys
|
||||
for (const remainingKey of flatKeys) {
|
||||
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,
|
||||
{},
|
||||
existingValue as MergeableObject,
|
||||
newValue as MergeableObject,
|
||||
);
|
||||
} else {
|
||||
v2Settings[remainingKey] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
return v2Settings;
|
||||
}
|
||||
|
||||
function getNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): unknown {
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (typeof current !== 'object' || current === null || !(key in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
const REVERSE_MIGRATION_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]),
|
||||
);
|
||||
|
||||
// Dynamically determine the top-level keys from the V2 settings structure.
|
||||
const KNOWN_V2_CONTAINERS = new Set(
|
||||
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
|
||||
);
|
||||
|
||||
export function migrateSettingsToV1(
|
||||
v2Settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const v1Settings: Record<string, unknown> = {};
|
||||
const v2Keys = new Set(Object.keys(v2Settings));
|
||||
|
||||
for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) {
|
||||
const value = getNestedProperty(v2Settings, newPath);
|
||||
if (value !== undefined) {
|
||||
v1Settings[oldKey] = value;
|
||||
v2Keys.delete(newPath.split('.')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve mcpServers at the top level
|
||||
if (v2Settings['mcpServers']) {
|
||||
v1Settings['mcpServers'] = v2Settings['mcpServers'];
|
||||
v2Keys.delete('mcpServers');
|
||||
}
|
||||
|
||||
// Carry over any unrecognized keys
|
||||
for (const remainingKey of v2Keys) {
|
||||
const value = v2Settings[remainingKey];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't carry over empty objects that were just containers for migrated settings.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(remainingKey) &&
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
v1Settings[remainingKey] = value;
|
||||
}
|
||||
|
||||
return v1Settings;
|
||||
return defaults as Settings;
|
||||
}
|
||||
|
||||
function mergeSettings(
|
||||
@@ -431,16 +218,18 @@ function mergeSettings(
|
||||
isTrusted: boolean,
|
||||
): Settings {
|
||||
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
|
||||
const schemaDefaults = getDefaultsFromSchema();
|
||||
|
||||
// Settings are merged with the following precedence (last one wins for
|
||||
// single values):
|
||||
// 1. System Defaults
|
||||
// 2. User Settings
|
||||
// 3. Workspace Settings
|
||||
// 4. System Settings (as overrides)
|
||||
// 1. Schema Defaults (Built-in)
|
||||
// 2. System Defaults
|
||||
// 3. User Settings
|
||||
// 4. Workspace Settings
|
||||
// 5. System Settings (as overrides)
|
||||
return customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
{}, // Start with an empty object
|
||||
schemaDefaults,
|
||||
systemDefaults,
|
||||
user,
|
||||
safeWorkspace,
|
||||
@@ -455,14 +244,14 @@ export class LoadedSettings {
|
||||
user: SettingsFile,
|
||||
workspace: SettingsFile,
|
||||
isTrusted: boolean,
|
||||
migratedInMemoryScopes: Set<SettingScope>,
|
||||
errors: SettingsError[] = [],
|
||||
) {
|
||||
this.system = system;
|
||||
this.systemDefaults = systemDefaults;
|
||||
this.user = user;
|
||||
this.workspace = workspace;
|
||||
this.isTrusted = isTrusted;
|
||||
this.migratedInMemoryScopes = migratedInMemoryScopes;
|
||||
this.errors = errors;
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
|
||||
@@ -471,7 +260,7 @@ export class LoadedSettings {
|
||||
readonly user: SettingsFile;
|
||||
readonly workspace: SettingsFile;
|
||||
readonly isTrusted: boolean;
|
||||
readonly migratedInMemoryScopes: Set<SettingScope>;
|
||||
readonly errors: SettingsError[];
|
||||
|
||||
private _merged: Settings;
|
||||
|
||||
@@ -510,6 +299,7 @@ export class LoadedSettings {
|
||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,7 +410,6 @@ export function loadSettings(
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
const systemSettingsPath = getSystemSettingsPath();
|
||||
const systemDefaultsPath = getSystemDefaultsPath();
|
||||
const migratedInMemoryScopes = new Set<SettingScope>();
|
||||
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
@@ -641,10 +430,7 @@ export function loadSettings(
|
||||
workspaceDir,
|
||||
).getWorkspaceSettingsPath();
|
||||
|
||||
const loadAndMigrate = (
|
||||
filePath: string,
|
||||
scope: SettingScope,
|
||||
): { settings: Settings; rawJson?: string } => {
|
||||
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
@@ -658,77 +444,49 @@ export function loadSettings(
|
||||
settingsErrors.push({
|
||||
message: 'Settings file is not a valid JSON object.',
|
||||
path: filePath,
|
||||
severity: 'error',
|
||||
});
|
||||
return { settings: {} };
|
||||
}
|
||||
|
||||
let settingsObject = rawSettings as Record<string, unknown>;
|
||||
if (needsMigration(settingsObject)) {
|
||||
const migratedSettings = migrateSettingsToV2(settingsObject);
|
||||
if (migratedSettings) {
|
||||
if (MIGRATE_V2_OVERWRITE) {
|
||||
try {
|
||||
fs.renameSync(filePath, `${filePath}.orig`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(migratedSettings, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'Failed to migrate settings file.',
|
||||
e,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
migratedInMemoryScopes.add(scope);
|
||||
}
|
||||
settingsObject = migratedSettings;
|
||||
}
|
||||
}
|
||||
const settingsObject = rawSettings as Record<string, unknown>;
|
||||
|
||||
// Validate settings structure with Zod after migration
|
||||
// Validate settings structure with Zod
|
||||
const validationResult = validateSettings(settingsObject);
|
||||
if (!validationResult.success && validationResult.error) {
|
||||
const errorMessage = formatValidationError(
|
||||
validationResult.error,
|
||||
filePath,
|
||||
);
|
||||
throw new FatalConfigError(errorMessage);
|
||||
settingsErrors.push({
|
||||
message: errorMessage,
|
||||
path: filePath,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
return { settings: settingsObject as Settings, rawJson: content };
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Preserve FatalConfigError with formatted validation messages
|
||||
if (error instanceof FatalConfigError) {
|
||||
throw error;
|
||||
}
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: filePath,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
return { settings: {} };
|
||||
};
|
||||
|
||||
const systemResult = loadAndMigrate(systemSettingsPath, SettingScope.System);
|
||||
const systemDefaultsResult = loadAndMigrate(
|
||||
systemDefaultsPath,
|
||||
SettingScope.SystemDefaults,
|
||||
);
|
||||
const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
|
||||
const systemResult = load(systemSettingsPath);
|
||||
const systemDefaultsResult = load(systemDefaultsPath);
|
||||
const userResult = load(USER_SETTINGS_PATH);
|
||||
|
||||
let workspaceResult: { settings: Settings; rawJson?: string } = {
|
||||
settings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
if (realWorkspaceDir !== realHomeDir) {
|
||||
workspaceResult = loadAndMigrate(
|
||||
workspaceSettingsPath,
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
workspaceResult = load(workspaceSettingsPath);
|
||||
}
|
||||
|
||||
const systemOriginalSettings = structuredClone(systemResult.settings);
|
||||
@@ -779,10 +537,10 @@ export function loadSettings(
|
||||
// the settings to avoid a cycle
|
||||
loadEnvironment(tempMergedSettings);
|
||||
|
||||
// Create LoadedSettings first
|
||||
|
||||
if (settingsErrors.length > 0) {
|
||||
const errorMessages = settingsErrors.map(
|
||||
// Check for any fatal errors before proceeding
|
||||
const fatalErrors = settingsErrors.filter((e) => e.severity === 'error');
|
||||
if (fatalErrors.length > 0) {
|
||||
const errorMessages = fatalErrors.map(
|
||||
(error) => `Error in ${error.path}: ${error.message}`,
|
||||
);
|
||||
throw new FatalConfigError(
|
||||
@@ -816,36 +574,10 @@ export function loadSettings(
|
||||
rawJson: workspaceResult.rawJson,
|
||||
},
|
||||
isTrusted,
|
||||
migratedInMemoryScopes,
|
||||
settingsErrors,
|
||||
);
|
||||
}
|
||||
|
||||
export function migrateDeprecatedSettings(
|
||||
loadedSettings: LoadedSettings,
|
||||
extensionManager: ExtensionManager,
|
||||
): void {
|
||||
const processScope = (scope: LoadableSettingScope) => {
|
||||
const settings = loadedSettings.forScope(scope).settings;
|
||||
if (settings.extensions?.disabled) {
|
||||
debugLogger.log(
|
||||
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
|
||||
);
|
||||
for (const extension of settings.extensions.disabled ?? []) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
extensionManager.disableExtension(extension, scope);
|
||||
}
|
||||
|
||||
const newExtensionsValue = { ...settings.extensions };
|
||||
newExtensionsValue.disabled = undefined;
|
||||
|
||||
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
|
||||
}
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
processScope(SettingScope.Workspace);
|
||||
}
|
||||
|
||||
export function saveSettings(settingsFile: SettingsFile): void {
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
@@ -854,12 +586,7 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
let settingsToSave = settingsFile.originalSettings;
|
||||
if (!MIGRATE_V2_OVERWRITE) {
|
||||
settingsToSave = migrateSettingsToV1(
|
||||
settingsToSave as Record<string, unknown>,
|
||||
) as Settings;
|
||||
}
|
||||
const settingsToSave = settingsFile.originalSettings;
|
||||
|
||||
// Use the format-preserving update function
|
||||
updateSettingsFilePreservingFormat(
|
||||
@@ -874,3 +601,18 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveModelChange(
|
||||
loadedSettings: LoadedSettings,
|
||||
model: string,
|
||||
): void {
|
||||
try {
|
||||
loadedSettings.setValue(SettingScope.User, 'model.name', model);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'There was an error saving your preferred model.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,22 +352,30 @@ describe('SettingsSchema', () => {
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe('Enable local and remote subagents.');
|
||||
expect(setting.description).toBe(
|
||||
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
|
||||
);
|
||||
});
|
||||
|
||||
it('should have isModelAvailabilityServiceEnabled setting in schema', () => {
|
||||
const setting =
|
||||
getSettingsSchema().experimental.properties
|
||||
.isModelAvailabilityServiceEnabled;
|
||||
it('should have hooks.notifications setting in schema', () => {
|
||||
const setting = getSettingsSchema().hooks.properties.notifications;
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
'Enable model routing using new availability service.',
|
||||
);
|
||||
expect(setting.category).toBe('Advanced');
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.showInDialog).toBe(true);
|
||||
});
|
||||
|
||||
it('should have name and description in hook definitions', () => {
|
||||
const hookDef = SETTINGS_SCHEMA_DEFINITIONS['HookDefinitionArray'];
|
||||
expect(hookDef).toBeDefined();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hookItemProperties = (hookDef as any).items.properties.hooks.items
|
||||
.properties;
|
||||
expect(hookItemProperties.name).toBeDefined();
|
||||
expect(hookItemProperties.name.type).toBe('string');
|
||||
expect(hookItemProperties.description).toBeDefined();
|
||||
expect(hookItemProperties.description.type).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CustomTheme } from '../ui/themes/theme.js';
|
||||
import type { SessionRetentionSettings } from './settings.js';
|
||||
@@ -953,6 +953,16 @@ const SETTINGS_SCHEMA = {
|
||||
'The maximum time in seconds allowed without output from the shell command. Defaults to 5 minutes.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableShellOutputEfficiency: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Shell Output Efficiency',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Enable shell output efficiency optimizations for better performance.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
autoAccept: {
|
||||
@@ -1063,26 +1073,14 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'The number of lines to keep when truncating tool output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableMessageBusIntegration: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Message Bus Integration',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: oneLine`
|
||||
Enable policy-based tool confirmation via message bus integration.
|
||||
When enabled, tools automatically respect policy engine decisions (ALLOW/DENY/ASK_USER) without requiring individual tool implementations.
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
enableHooks: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Hooks System',
|
||||
label: 'Enable Hooks System (Experimental)',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description:
|
||||
'Enable the hooks system for intercepting and customizing Gemini CLI behavior. When enabled, hooks configured in settings will execute at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.). Requires MessageBus integration.',
|
||||
'Enables the hooks system experiment. When disabled, the hooks system is completely deactivated regardless of other settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
@@ -1128,15 +1126,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
useSmartEdit: {
|
||||
type: 'boolean',
|
||||
label: 'Use Smart Edit',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Enable the smart-edit tool instead of the replace tool.',
|
||||
showInDialog: false,
|
||||
},
|
||||
useWriteTodos: {
|
||||
type: 'boolean',
|
||||
label: 'Use WriteTodos',
|
||||
@@ -1164,6 +1153,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable YOLO mode, even if enabled by a flag.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enablePermanentToolApproval: {
|
||||
type: 'boolean',
|
||||
label: 'Allow Permanent Tool Approval',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
|
||||
showInDialog: true,
|
||||
},
|
||||
blockGitExtensions: {
|
||||
type: 'boolean',
|
||||
label: 'Blocks extensions from Git',
|
||||
@@ -1193,6 +1192,48 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
environmentVariableRedaction: {
|
||||
type: 'object',
|
||||
label: 'Environment Variable Redaction',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for environment variable redaction.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
allowed: {
|
||||
type: 'array',
|
||||
label: 'Allowed Environment Variables',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'Environment variables to always allow (bypass redaction).',
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
blocked: {
|
||||
type: 'array',
|
||||
label: 'Blocked Environment Variables',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: [] as string[],
|
||||
description: 'Environment variables to always redact.',
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Environment Variable Redaction',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable redaction of environment variables that may contain secrets.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
type: 'object',
|
||||
label: 'Authentication',
|
||||
@@ -1301,7 +1342,8 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable local and remote subagents.',
|
||||
description:
|
||||
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionManagement: {
|
||||
@@ -1323,15 +1365,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Enables extension loading/unloading within the CLI session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
isModelAvailabilityServiceEnabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Model Availability Service',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable model routing using new availability service.',
|
||||
showInDialog: false,
|
||||
},
|
||||
jitContext: {
|
||||
type: 'boolean',
|
||||
label: 'JIT Context Loading',
|
||||
@@ -1341,6 +1374,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable Just-In-Time (JIT) context loading.',
|
||||
showInDialog: false,
|
||||
},
|
||||
skills: {
|
||||
type: 'boolean',
|
||||
label: 'Agent Skills',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable Agent Skills (experimental).',
|
||||
showInDialog: true,
|
||||
},
|
||||
codebaseInvestigatorSettings: {
|
||||
type: 'object',
|
||||
label: 'Codebase Investigator Settings',
|
||||
@@ -1394,13 +1436,43 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: GEMINI_MODEL_ALIAS_PRO,
|
||||
default: GEMINI_MODEL_ALIAS_AUTO,
|
||||
description:
|
||||
'The model to use for the Codebase Investigator agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
useOSC52Paste: {
|
||||
type: 'boolean',
|
||||
label: 'Use OSC 52 Paste',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).',
|
||||
showInDialog: true,
|
||||
},
|
||||
introspectionAgentSettings: {
|
||||
type: 'object',
|
||||
label: 'Introspection Agent Settings',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Configuration for Introspection Agent.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Introspection Agent',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable the Introspection Agent.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1439,6 +1511,29 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
type: 'object',
|
||||
label: 'Skills',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Settings for agent skills.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Skills',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: [] as string[],
|
||||
description: 'List of disabled skills.',
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
type: 'object',
|
||||
label: 'Hooks',
|
||||
@@ -1449,6 +1544,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Hook configurations for intercepting and customizing agent behavior.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
},
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Hooks',
|
||||
@@ -1464,6 +1569,15 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
notifications: {
|
||||
type: 'boolean',
|
||||
label: 'Hook Notifications',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Show visual indicators when hooks are executing.',
|
||||
showInDialog: true,
|
||||
},
|
||||
BeforeTool: {
|
||||
type: 'array',
|
||||
label: 'Before Tool Hooks',
|
||||
@@ -1604,6 +1718,74 @@ const SETTINGS_SCHEMA = {
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
type: 'object',
|
||||
label: 'Admin',
|
||||
category: 'Admin',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings configured remotely by enterprise admins.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
properties: {
|
||||
secureModeEnabled: {
|
||||
type: 'boolean',
|
||||
label: 'Secure Mode Enabled',
|
||||
category: 'Admin',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'If true, disallows yolo mode from being used.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
extensions: {
|
||||
type: 'object',
|
||||
label: 'Extensions Settings',
|
||||
category: 'Admin',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Extensions-specific admin settings.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Extensions Enabled',
|
||||
category: 'Admin',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'If false, disallows extensions from being installed or used.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
type: 'object',
|
||||
label: 'MCP Settings',
|
||||
category: 'Admin',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'MCP-specific admin settings.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'MCP Enabled',
|
||||
category: 'Admin',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'If false, disallows MCP servers from being used.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies SettingsSchema;
|
||||
|
||||
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
|
||||
@@ -1640,7 +1822,8 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'SSE transport URL.',
|
||||
description:
|
||||
'URL for SSE or HTTP transport. Use with "type" field to specify transport type.',
|
||||
},
|
||||
httpUrl: {
|
||||
type: 'string',
|
||||
@@ -1655,6 +1838,12 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
type: 'string',
|
||||
description: 'TCP address for websocket transport.',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Transport type. Use "stdio" for local command, "sse" for Server-Sent Events, or "http" for Streamable HTTP.',
|
||||
enum: ['stdio', 'sse', 'http'],
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds for MCP requests.',
|
||||
@@ -1899,6 +2088,10 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
type: 'object',
|
||||
description: 'Individual hook configuration.',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Unique identifier for the hook.',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
description:
|
||||
@@ -1909,6 +2102,10 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
description:
|
||||
'Shell command to execute. Receives JSON input via stdin and returns JSON output via stdout.',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'A description of the hook.',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds for hook execution.',
|
||||
@@ -1939,8 +2136,8 @@ type InferSettings<T extends SettingsSchema> = {
|
||||
|
||||
export type Settings = InferSettings<SettingsSchemaType>;
|
||||
|
||||
export interface FooterSettings {
|
||||
hideCWD?: boolean;
|
||||
hideSandboxStatus?: boolean;
|
||||
hideModelInfo?: boolean;
|
||||
export function getEnableHooks(settings: Settings): boolean {
|
||||
return (
|
||||
(settings.tools?.enableHooks ?? true) && (settings.hooks?.enabled ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('Settings Repro', () => {
|
||||
showColor: true,
|
||||
enableInteractiveShell: true,
|
||||
},
|
||||
enableMessageBusIntegration: true,
|
||||
truncateToolOutputLines: 100,
|
||||
},
|
||||
experimental: {
|
||||
useModelRouter: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user