Compare commits

..

5 Commits

Author SHA1 Message Date
gemini-cli-robot cd4bebba9e chore(release): v0.12.0-preview.3 2025-10-30 22:44:31 +00:00
gemini-cli-robot 3876379bf7 fix(patch): cherry-pick 82c1042 to release/v0.12.0-preview.2-pr-12231 to patch version v0.12.0-preview.2 and create version 0.12.0-preview.3 (#12320)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2025-10-30 22:28:07 +00:00
gemini-cli-robot 7124551aa5 chore(release): v0.12.0-preview.2 2025-10-30 22:07:18 +00:00
matt korwel b4a63bf73b Merge commit 'b382ae6803ce21ead2a91682fc58126f3786f15b' into HEAD 2025-10-30 14:52:52 -07:00
gemini-cli-robot fe98c85535 chore(release): v0.12.0-preview.0 2025-10-29 00:57:46 +00:00
542 changed files with 15571 additions and 40900 deletions
-140
View File
@@ -1,140 +0,0 @@
description="Reviews a pull request based on issue number."
prompt = """
Please provide a detailed pull request review on GitHub issue: {{args}}.
Follow these steps:
1. Use `gh pr view {{args}}` to pull the information of the PR.
2. Use `gh pr diff {{args}}` to view the diff of the PR.
3. Understand the intent of the PR using the PR description.
4. If PR description is not detailed enough to understand the intent of the PR,
make sure to note it in your review.
5. Make sure the PR title follows Conventional Commits, here are the last five
commits to the repo as examples: !{git log --pretty=format:"%s" -n 5}
6. Search the codebase if required.
7. Write a concise review of the PR, keeping in mind to encourage strong code
quality and best practices. Pay particular attention to the Gemini MD file
in the repo.
8. Consider ways the code may not be consistent with existing code in the repo.
In particular it is critical that the react code uses patterns consistent
with existing code in the repo.
9. Evaluate all tests on the PR and make sure that they are doing the following:
* Using `waitFor` from @{packages/cli/src/test-utils/async.ts} rather than
using `vi.waitFor` for all `waitFor` calls within `packages/cli`. Even if
tests pass, using the wrong `waitFor` could result in flaky tests as `act`
warnings could show up if timing is slightly different.
* Using `act` to wrap all blocks in tests that change component state.
* Using `toMatchSnapshot` to verify that rendering works as expected rather
than matching against the raw content of the output.
* If snapshots were changed as part of the pull request, review the snapshots
changes to ensure they are intentional and comment if any look at all
suspicious. Too many snapshot changes that indicate bugs have been approved
in the past.
* Use `render` or `renderWithProviders` from
@{packages/cli/src/test-utils/render.tsx} rather than using `render` from
`ink-testing-library` directly. This is needed to ensure that we do not get
warnings about spurious `act` calls. If test cases specify providers
directly, consider whether the existing `renderWithProviders` should be
modified to support that use case.
* Ensure the test cases are using parameterized tests where that might reduce
the number of duplicated lines significantly.
* NEVER use fixed waits (e.g. 'await delay(100)'). Always use 'waitFor' with
a predicate to ensure tests are stable and fast.
* Ensure mocks are properly managed:
* Critical dependencies (fs, os, child_process) should only be mocked at
the top of the file. Ideally avoid mocking these dependencies altogether.
* Check to see if there are existing mocks or fakes that can be used rather
than creating new ones for the new tests added.
* Try to avoid mocking the file system whenever possible. If using the real
file system is difficult consider whether the test should be an
integration test rather than a unit test.
* `vi.restoreAllMocks()` should be called in `afterEach` to prevent test
pollution.
* Use `vi.useFakeTimers()` for tests involving time-based logic to avoid
flakiness.
* Avoid using `any` in tests; prefer proper types or `unknown` with
narrowing.
* When creating parameterized tests, give the parameters types to ensure
that the tests are type-safe.
10. Evaluate all react logic carefully keeping in mind that the author of the PR
is not likely an expert on React. Key areas to audit carefully are:
* Whether `setState` calls trigger side effects from within the body of the
`setState` callback. If so, you *must* propose an alternate design using
reducers or other ways the code might be modified to not have to modify
state from within a `setState`. Make sure to comment about absolutely
every case like this as these cases have introduced multiple bugs in the
past. Typically these cases should be resolved using a reducer although
occassionally other techniques such as useRef are appropriate. Consider
suggesting that jacob314@ be tagged on the code review if the solution is
not 100% obvious.
* Whether code might introduce an infinite rendering loop in React.
* Whether keyboard handling is robust. Keyboard handling must go through
`useKeyPress.ts` from the Gemini CLI package rather than using the
standard ink library used by most keyboard handling. Unlike the standard
ink library, the keyboard handling library in Gemini CLI may report
multiple keyboard events one after another in the same React frame. This
is needed to support slow terminals but introduces complexity in all our
code that handles keyboard events. Handling this correctly often means
that reducers must be used or other mechanisms to ensure that multiple
state updates one after another are handled gracefully rather than
overriding values from the first update with the second update. Refer to
text-buffer.ts as a canonical example of using a reducer for this sort of
case.
* Ensure code does not use `console.log`, `console.warn`, or `console.error`
as these indicate debug logging that was accidentally left in the code.
* Avoid synchronous file I/O in React components as it will hang the UI.
* Ensure state initialization is explicit (e.g., use 'undefined' rather than
'true' as a default if the state is truly unknown initially).
* Carefully manage 'useEffect' dependencies. Prefer to use a reducer
whenever practical to resolve the issues. If that is not practical it is
ok to use 'useRef' to access the latest value of a prop or state inside an
effect without adding it to the dependency array if re-running the effect
is undesirable (common in event listeners).
* NEVER disable 'react-hooks/exhaustive-deps'. Fix the code to correctly
declare dependencies. Disabling this lint rule will almost always lead to
hard to detect bugs.
* Avoid making types nullable unless strictly necessary, as it hurts
readability.
* Do not introduce excessive property drilling. There are multiple providers
that can be leveraged to avoid property drilling. Make sure one of them
cannot be used. Do suggest a provider that might make sense to be extended
to include the new property or propose a new provider to add if the
property drilling is excessive. Only use providers for properties that are
consistent for the entire application.
11. General Gemini CLI design principles:
* Make sure that settings are only used for options that a user might
consider changing.
* Do not add new command line arguments and suggest settings instead.
* New settings must be added to packages/cli/src/config/settingsSchema.ts.
* If a setting has 'showInDialog: true', it MUST be documented in
docs/get-started/configuration.md.
* Ensure 'requiresRestart' is correctly set for new settings.
* Use 'debugLogger' for rethrown errors to avoid duplicate logging.
* All new keyboard shortcuts MUST be documented in
docs/cli/keyboard-shortcuts.md.
* Ensure new keyboard shortcuts are defined in
packages/cli/src/config/keyBindings.ts.
* If new keyboard shortcuts are added, remind the user to test them in
VSCode, iTerm2, Ghostty, and Windows to ensure they work for all
users.
* Be careful of keybindings that require the meta key as only certain
meta key shortcuts are supported on Mac.
* Be skeptical of function keys and keyboard shortcuts that are commonly
bound in VSCode as they may conflict.
12. TypeScript Best Practices:
* Use 'checkExhaustive' in the 'default' clause of 'switch' statements to
ensure all cases are handled.
* Avoid using the non-null assertion operator ('!') unless absolutely
necessary and you are confident the value is not null.
13. If the change might at all impact the prompts sent to Gemini CLI, flagged
that the change could impact Gemini CLI quality and make sure anj-s has been
tagged on the code review.
14. Discuss with me before making any comments on the issue. I will clarify
which possible issues you identified are problems, which ones you need to
investigate further, and which ones I do not care about.
15. If I request you to add comments to the issue, use
`gh pr comment {{args}} --body {{review}}` to post the review to the PR.
Remember to use the GitHub CLI (`gh`) with the Shell tool for all
GitHub-related tasks.
"""
+1 -1
View File
@@ -1,8 +1,8 @@
name: 'Bug Report'
description: 'Report a bug to help us improve Gemini CLI'
labels:
- 'kind/bug'
- 'status/need-triage'
type: 'Bug'
body:
- type: 'markdown'
attributes:
+1 -1
View File
@@ -1,8 +1,8 @@
name: 'Feature Request'
description: 'Suggest an idea for this project'
labels:
- 'kind/enhancement'
- 'status/need-triage'
type: 'Feature'
body:
- type: 'markdown'
attributes:
+2 -1
View File
@@ -1,7 +1,8 @@
name: 'Website issue'
description: 'Report an issue with the Gemini CLI Website and Gemini CLI Extensions Gallery'
labels:
- 'area/extensions'
- 'area/website'
- 'status/need-triage'
body:
- type: 'markdown'
attributes:
-6
View File
@@ -90,12 +90,6 @@ jobs:
- name: 'Run Prettier'
run: 'node scripts/lint.js --prettier'
- name: 'Build docs prerequisites'
run: 'npm run predocs:settings'
- name: 'Verify settings docs'
run: 'npm run docs:settings -- --check'
- name: 'Run sensitive keyword linter'
run: 'node scripts/lint.js --sensitive-keywords'
@@ -45,9 +45,7 @@ jobs:
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR')
))
)
) &&
!contains(github.event.issue.labels.*.name, 'area/') &&
!contains(github.event.issue.labels.*.name, 'priority/')
)
timeout-minutes: 5
runs-on: 'ubuntu-latest'
steps:
@@ -69,24 +67,14 @@ jobs:
core.setOutput('labels', issue.labels.map(label => label.name).join(','));
return issue;
- name: 'Manual Trigger Pre-flight Checks'
- name: 'Check for triage label on manual trigger'
if: |-
github.event_name == 'workflow_dispatch'
github.event_name == 'workflow_dispatch' && !contains(steps.get_issue_data.outputs.labels, 'status/need-triage')
env:
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/' || echo "${LABELS}" | grep -q 'priority/'; then
echo "Issue #${ISSUE_NUMBER_INPUT} already has 'area/' or 'priority/' labels. Stopping workflow."
exit 1
fi
echo "Manual triage checks passed."
echo "Issue #${ISSUE_NUMBER_INPUT} does not have the 'status/need-triage' label. Stopping workflow."
exit 1
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
@@ -109,22 +97,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
});
const allowedLabels = [
'priority/p0',
'priority/p1',
'priority/p2',
'priority/p3',
'priority/unknown',
'area/agent',
'area/enterprise',
'area/non-interactive',
'area/core',
'area/security',
'area/platform',
'area/extensions',
'area/unknown'
];
const labelNames = labels.map(label => label.name).filter(name => allowedLabels.includes(name));
const labelNames = labels.map(label => label.name);
core.setOutput('available_labels', labelNames.join(','));
core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`);
return labelNames;
@@ -153,6 +126,9 @@ jobs:
settings: |-
{
"maxSessionTurns": 25,
"coreTools": [
"run_shell_command(echo)"
],
"telemetry": {
"enabled": true,
"target": "gcp"
@@ -161,134 +137,128 @@ jobs:
prompt: |-
## Role
You are an issue triage assistant. Your role is to analyze a GitHub issue and determine the single most appropriate area/ label and the single most appropriate priority/ label based on the definitions provided.
You are an issue triage assistant. Analyze the current GitHub issue
and identify the most appropriate existing labels by only using the provided data. Use the available
tools to gather information; do not ask for information to be
provided. Do not remove the following labels titled maintainer, help wanted or good first issue.
## Steps
1. Review the issue title and body: ${{ env.ISSUE_TITLE }} and ${{ env.ISSUE_BODY }}.
2. Review the available labels: ${{ env.AVAILABLE_LABELS }}.
3. Select exactly one area/ label that best matches the issue based on Reference 1: Area Definitions.
4. Select exactly one priority/ label that best matches the issue based on Reference 2: Priority Definitions.
5. Fallback Logic:
- If you cannot confidently determine the correct area/ label from the definitions, you must use area/unknown.
- If you cannot confidently determine the correct priority/ label from the definitions, you must use priority/unknown.
6. Output your two selected labels in JSON format and nothing else. Example:
{"labels_to_set": ["area/core", "priority/p1"]}
1. You are only able to use the echo command. Review the available labels in the environment variable: "${AVAILABLE_LABELS}".
2. Review the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
3. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
4. If the issue already has area/ label, dont try to change it. Similarly, if the issue already has a kind/ label don't change it. And if the issue already has a priority/ label do not change it for example:
If an issue has area/core and kind/bug you will only add a priority/ label.
Instead if an issue has no labels, you will could add one lable of each kind.
5. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label.
6. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
7. Output the appropriate labels for this issue in JSON format with explanation, for example:
```
{"labels_to_set": ["kind/bug", "priority/p0"], "explanation": "This is a critical bug report affecting main functionality"}
```
8. If the issue cannot be classified using the available labels, output:
```
{"labels_to_set": [], "explanation": "Unable to classify this issue with available labels"}
```
9. Use Area definitions mentioned below to help you narrow down issues.
10. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
11. If you are uncertain and have not been able to apply one each of kind/, area/ and priority/ , apply the status/manual-triage label.
## Guidelines
- Your output must contain exactly one area/ label and exactly one priority/ label.
- Triage only the current issue based on its title and body.
- Output only valid JSON format.
- Do not include any explanation or additional text, just the JSON.
Reference 1: Area Definitions
area/agent
- Description: Issues related to the "brain" of the CLI. This includes the core agent logic, model quality, tool/function calling, and memory.
- Example Issues:
"I am not getting a reasonable or expected response."
"The model is not calling the tool I expected."
"The web search tool is not working as expected."
"Feature request for a new built-in tool (e.g., read file, write file)."
"The generated code is poor quality or incorrect."
"The model seems stuck in a loop."
"The response from the model is malformed (e.g., broken JSON, bad formatting)."
"Concerns about unnecessary token consumption."
"Issues with how memory or chat history is managed."
"Issues with sub-agents."
"Model is switching from one to another unexpectedly."
- Only use labels that already exist in the repository
- Do not add comments or modify the issue content
- Triage only the current issue
- Identify only one area/ label
- Identify only one kind/ label
- Identify all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario
- Reference all shell variables as "${VAR}" (with quotes and braces)
- Output only valid JSON format
- Do not include any explanation or additional text, just the JSON
area/enterprise
- Description: Issues specific to enterprise-level features, including telemetry, policy, and licenses.
- Example Issues:
"Usage data is not appearing in our telemetry dashboard."
"A user is able to perform an action that should be blocked by an admin policy."
"Questions about billing, licensing tiers, or enterprise quotas."
area/non-interactive
- Description: Issues related to using the CLI in automated or non-interactive environments (headless mode).
- Example Issues:
"Problems using the CLI as an SDK in another surface."
"The CLI is behaving differently when run from a shell script vs. an interactive terminal."
"GitHub action is failing."
"I am having trouble running the CLI in headless mode"
area/core
- Description: Issues with the fundamental CLI app itself. This includes the user interface (UI/UX), installation, OS compatibility, and performance.
- Example Issues:
"I am seeing my screen flicker when using the CLI."
"The output in my terminal is malformed or unreadable."
"Theme changes are not taking effect."
"Keyboard inputs (e.g., arrow keys, Ctrl+C) are not being recognized."
"The CLI failed to install or update."
"An issue specific to running on Windows, macOS, or Linux."
"Problems with command parsing, flags, or argument handling."
"High CPU or memory usage by the CLI process."
"Issues related to multi-modality (e.g., handling image inputs)."
"Problems with the IDE integration connection or installation"
area/security
- Description: Issues related to user authentication, authorization, data security, and privacy.
- Example Issues:
"I am unable to sign in."
"The login flow is selecting the wrong authentication path"
"Problems with API key handling or credential storage."
"A report of a security vulnerability"
"Concerns about data sanitization or potential data leaks."
"Issues or requests related to privacy controls."
"Preventing unauthorized data access."
area/platform
- Description: Issues related to CI/CD, release management, testing, eval infrastructure, capacity, quota management, and sandbox environments.
- Example Issues:
"I am getting a 429 'Resource Exhausted' or 500-level server error."
"General slowness or high latency from the service."
"The build script is broken on the main branch."
"Tests are failing in the CI/CD pipeline."
"Issues with the release management or publishing process."
"User is running out of capacity."
"Problems specific to the sandbox or staging environments."
"Questions about quota limits or requests for increases."
area/extensions
- Description: Issues related to the extension ecosystem, including the marketplace and website.
- Example Issues:
"Bugs related to the extension marketplace website."
"Issues with a specific extension."
"Feature request for the extension ecosystem."
area/unknown
- Description: Issues that do not clearly fit into any other defined area/ category, or where information is too limited to make a determination. Use this when no other area is appropriate.
Reference 2: Priority Definitions
priority/p0: Critical / Blocker
- Definition: A catastrophic failure that makes the CLI unusable for most users or poses a severe security risk. This includes installation failures, authentication failures, persistent crashes, or critical security vulnerabilities.
- Key Questions:
- Is the CLI failing to install or run?
- Does it fail to authenticate or connect to the Gemini API, making all commands useless?
- Is it consistently crashing on basic, common commands?
- Does this represent a critical security vulnerability?
priority/p1: High
- Definition: A severe issue where a core feature (e.g., text generation, code generation, file processing) is unusable, failing, has severe performance degradation, or providing fundamentally incorrect output formatting (e.g., truncated text, broken JSON). Affects many users, and there is no reasonable workaround.
- Key Questions:
- Is a core feature failing for a specific, large user group (e.g., all Windows users, all users of a specific shell)?
- Is the CLI failing to process a supported input or misinterpreting critical flags?
- Is the CLI's output formatting consistently broken, making the response unusable?
- Is a core command or feature extremely slow, making it impractical to use?
priority/p2: Medium
- Definition: A moderately impactful issue causing inconvenience or a non-optimal experience, but a reasonable workaround exists. This also includes failures in non-core features.
- Key Questions:
- Is a command or flag behaving incorrectly, but the user can achieve their goal via other means?
- Is there a significant, non-blocking UI/UX problem in the terminal (e.g., broken progress indicators, bad terminal coloring)?
priority/p3: Low
- Definition: A minor, low-impact issue with minimal effect on functionality. This includes most cosmetic defects, typos in documentation, or unclear help text.
- Key Questions:
- Is this a typo in the README.md, gemini --help text, or other documentation?
- Is this a minor cosmetic issue (e.g., text alignment in output, an extra newline) that doesn't affect usability?
priority/unknown
- Description: Issues that do not clearly fit into any other defined priority/ category, or where information is too limited to make a determination. Use this when no other priority is appropriate.
Categorization Guidelines:
P0: Critical / Blocker
- A P0 bug is a catastrophic failure that demands immediate attention.
- To be a P0 it means almost all users are running into this issue and it is blocking users from being able to use the product.
- You would see this in the form of many comments from different developers on the bug.
- It represents a complete showstopper for a significant portion of users or for the development process itself.
Impact:
- Blocks development or testing for the entire team.
- Major security vulnerability that could compromise user data or system integrity.
- Causes data loss or corruption with no workaround.
- Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? Is it preventing contributors from contributing to the repository or is it a release blocker?
Qualifier: Is the main function of the software broken?
Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
P1: High
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature.
- While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1.
- Once again this would be affecting many users.
- You would see this in the form of comments from different developers on the bug.
Impact:
- A core feature is broken or behaving incorrectly for a large number of users or large number of use cases.
- Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases.
- Severe performance degradation making the application frustratingly slow.
- No straightforward workaround exists, or the workaround is difficult and non-obvious.
Qualifier: Is a key feature unusable or giving very wrong results?
Example: Gemini CLI enters a loop when making read-many-files tool call. I am unable to break out of the loop and gemini doesn't follow instructions subsequently.
P2: Medium
- A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality.
Impact:
- Affects a non-critical feature or a smaller, specific subset of users.
- An inconvenient but functional workaround is available and easy to execute.
- Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping).
Qualifier: Is it an annoying but non-blocking problem?
Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow.
P3: Low
- A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application.
Impact:
- Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page.
- An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users.
Qualifier: Is it a "nice-to-fix" issue?
Example: Spelling mistakes etc.
Things you should know:
- If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue
- This product is designed to use different models eg.. using pro, downgrading to flash etc. when users report that they dont expect the model to change those would be categorized as feature requests.
Definition of Areas
area/ux:
- Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance.
- I am seeing my screen flicker when using Gemini CLI
- I am seeing the output malformed
- Theme changes aren't taking effect
- My keyboard inputs arent' being recognzied
area/platform:
- Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework.
area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features.
area/models:
- i am not getting a response that is reasonable or expected. this can include things like
- I am calling a tool and the tool is not performing as expected.
- i am expecting a tool to be called and it is not getting called ,
- Including experience when using
- built-in tools (e.g., web search, code interpreter, read file, writefile, etc..),
- Function calling issues should be under this area
- i am getting responses from the model that are malformed.
- Issues concerning Gemini quality of response and inference,
- Issues talking about unnecessary token consumption.
- Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues.
- Memory compression
- unexpected responses,
- poor quality of generated code
area/tools:
- These are primarily issues related to Model Context Protocol
- These are issues that mention MCP support
- feature requests asking for support for new tools.
area/core: Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality
area/contribution: Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure.
area/authentication: Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc..
area/security-privacy: Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access.
area/extensibility: Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc..
area/performance: Issues focused on model performance
- Issues with running out of capacity,
- 429 errors etc..
- could also pertain to latency,
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
- Switching models from one to the other unexpectedly.
- name: 'Apply Labels to Issue'
if: |-
@@ -300,61 +270,52 @@ jobs:
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |
const rawOutput = process.env.LABELS_OUTPUT;
core.info(`Raw output from model: ${rawOutput}`);
script: |-
// Strip code block markers if present
const rawLabels = process.env.LABELS_OUTPUT;
core.info(`Raw labels JSON: ${rawLabels}`);
let parsedLabels;
try {
// First, try to parse the raw output as JSON.
parsedLabels = JSON.parse(rawOutput);
} catch (jsonError) {
// If that fails, check for a markdown code block.
core.warning(`Direct JSON parsing failed: ${jsonError.message}. Trying to extract from a markdown block.`);
const jsonMatch = rawOutput.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
parsedLabels = JSON.parse(jsonMatch[1].trim());
} catch (markdownError) {
core.setFailed(`Failed to parse JSON even after extracting from markdown block: ${markdownError.message}\nRaw output: ${rawOutput}`);
return;
}
} else {
core.setFailed(`Output is not valid JSON and does not contain a JSON markdown block.\nRaw output: ${rawOutput}`);
return;
const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/);
if (!jsonMatch || !jsonMatch[1]) {
throw new Error("Could not find a ```json ... ``` block in the output.");
}
}
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
const labelsToAdd = parsedLabels.labels_to_set || [];
if (labelsToAdd.length !== 2) {
core.setFailed(`Expected exactly 2 labels (one area/ and one priority/), but got ${labelsToAdd.length}. Labels: ${labelsToAdd.join(', ')}`);
const jsonString = jsonMatch[1].trim();
parsedLabels = JSON.parse(jsonString);
core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`);
} catch (err) {
core.setFailed(`Failed to parse labels JSON from Gemini output: ${err.message}\nRaw output: ${rawLabels}`);
return;
}
// Set labels based on triage result
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToAdd
});
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`);
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
const explanation = parsedLabels.explanation || '';
const labelsToSet = parsedLabels.labels_to_set || [];
labelsToSet.push('status/bot-triaged');
// Remove the 'status/need-triage' label
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: 'status/need-triage'
});
core.info(`Successfully removed 'status/need-triage' label.`);
} catch (error) {
// If the label doesn't exist, the API call will throw a 404. We can ignore this.
if (error.status !== 404) {
core.warning(`Failed to remove 'status/need-triage': ${error.message}`);
}
// Set labels based on triage result
if (labelsToSet.length > 0) {
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToSet
});
const explanationInfo = explanation ? ` - ${explanation}` : '';
core.info(`Successfully set labels for #${issueNumber}: ${labelsToSet.join(', ')}${explanationInfo}`);
} else {
// If no labels to set, leave the issue as is
const explanationInfo = explanation ? ` - ${explanation}` : '';
core.info(`No labels to set for #${issueNumber}, leaving as is${explanationInfo}`);
}
if (explanation) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: explanation,
});
}
- name: 'Post Issue Analysis Failure Comment'
@@ -1,8 +1,5 @@
name: 'Release: Patch (1) Create PR'
run-name: >-
Release Patch (1) Create PR | S:${{ inputs.channel }} | C:${{ inputs.commit }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }}
on:
workflow_dispatch:
inputs:
@@ -99,12 +96,12 @@ jobs:
--channel="${PATCH_CHANNEL}" \
--pullRequestNumber="${ORIGINAL_PR}" \
--dry-run="${DRY_RUN}"
echo "EXIT_CODE=$?" >> "$GITHUB_OUTPUT"
} 2>&1 | tee >(
echo "LOG_CONTENT<<EOF" >> "$GITHUB_ENV"
cat >> "$GITHUB_ENV"
echo "EOF" >> "$GITHUB_ENV"
)
echo "EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: 'Comment on Original PR'
if: 'always() && inputs.original_pr'
@@ -118,7 +115,6 @@ jobs:
GITHUB_RUN_ID: '${{ github.run_id }}'
LOG_CONTENT: '${{ env.LOG_CONTENT }}'
TARGET_REF: '${{ github.event.inputs.ref }}'
ENVIRONMENT: '${{ github.event.inputs.environment }}'
continue-on-error: true
run: |
git checkout "${TARGET_REF}"
@@ -1,10 +1,5 @@
name: 'Release: Patch (2) Trigger'
run-name: >-
Release Patch (2) Trigger |
${{ github.event.pull_request.number && format('PR #{0}', github.event.pull_request.number) || 'Manual' }} |
${{ github.event.pull_request.head.ref || github.event.inputs.ref }}
on:
pull_request:
types:
@@ -1,8 +1,5 @@
name: 'Release: Patch (3) Release'
run-name: >-
Release Patch (3) Release | T:${{ inputs.type }} | R:${{ inputs.release_ref }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }}
on:
workflow_dispatch:
inputs:
+2 -5
View File
@@ -92,9 +92,6 @@ jobs:
fi
NIGHTLY_COMMAND="node scripts/get-release-version.js --type=promote-nightly"
STABLE_JSON=$(${STABLE_COMMAND})
STABLE_VERSION=$(echo "${STABLE_JSON}" | jq -r .releaseVersion)
PREVIEW_COMMAND+=" --stable-base-version=${STABLE_VERSION}"
NIGHTLY_COMMAND+=" --stable-base-version=${STABLE_VERSION}"
PREVIEW_JSON=$(${PREVIEW_COMMAND})
NIGHTLY_JSON=$(${NIGHTLY_COMMAND})
echo "STABLE_JSON_COMMAND=${STABLE_COMMAND}"
@@ -103,14 +100,14 @@ jobs:
echo "STABLE_JSON: ${STABLE_JSON}"
echo "PREVIEW_JSON: ${PREVIEW_JSON}"
echo "NIGHTLY_JSON: ${NIGHTLY_JSON}"
echo "STABLE_VERSION=${STABLE_VERSION}" >> "${GITHUB_OUTPUT}"
echo "STABLE_VERSION=$(echo "${STABLE_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
# shellcheck disable=SC1083
echo "STABLE_SHA=$(git rev-parse "$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)"^{commit})" >> "${GITHUB_OUTPUT}"
echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
# shellcheck disable=SC1083
REF="${REF_INPUT}"
SHA=$(git ls-remote origin "$REF" | awk -v ref="$REF" '$2 == "refs/heads/"ref || $2 == "refs/tags/"ref || $2 == ref {print $1}' | head -n 1)
SHA=$(git ls-remote origin "$REF" | awk '{print $1}')
if [ -z "$SHA" ]; then
if [[ "$REF" =~ ^[0-9a-f]{7,40}$ ]]; then
SHA="$REF"
+1
View File
@@ -17,6 +17,7 @@
# Dependency directory
node_modules
bower_components
package-lock.json
# Editors
.idea
-15
View File
@@ -53,21 +53,6 @@ All submissions, including submissions by project members, require review. We
use [GitHub pull requests](https://docs.github.com/articles/about-pull-requests)
for this purpose.
If your pull request involves changes to `packages/cli` (the frontend), we
recommend running our automated frontend review tool. **Note: This tool is
currently experimental.** It helps detect common React anti-patterns, testing
issues, and other frontend-specific best practices that are easy to miss.
To run the review tool, enter the following command from within Gemini CLI:
```text
/review-frontend <PR_NUMBER>
```
Replace `<PR_NUMBER>` with your pull request number. Authors are encouraged to
run this on their own PRs for self-review, and reviewers should use it to
augment their manual review process.
### Self assigning issues
If you're looking for an issue to work on, check out our list of issues that are
-106
View File
@@ -3,112 +3,6 @@
Wondering what's new in Gemini CLI? This document provides key highlights and
notable changes to Gemini CLI.
## v0.12.0 - Gemini CLI weekly update - 2025-10-27
![Codebase investigator subagent in Gemini CLI.](https://i.imgur.com/4J1njsx.png)
- **🎉 New partner extensions:**
- **🤗 Hugging Face extension:** Access the Hugging Face hub.
([gif](https://drive.google.com/file/d/1LEzIuSH6_igFXq96_tWev11svBNyPJEB/view?usp=sharing&resourcekey=0-LtPTzR1woh-rxGtfPzjjfg))
`gemini extensions install https://github.com/huggingface/hf-mcp-server`
- **Monday.com extension**: Analyze your sprints, update your task boards,
etc.
([gif](https://drive.google.com/file/d/1cO0g6kY1odiBIrZTaqu5ZakaGZaZgpQv/view?usp=sharing&resourcekey=0-xEr67SIjXmAXRe1PKy7Jlw))
`gemini extensions install https://github.com/mondaycom/mcp`
- **Data Commons extension:** Query public datasets or ground responses on
data from Data Commons
([gif](https://drive.google.com/file/d/1cuj-B-vmUkeJnoBXrO_Y1CuqphYc6p-O/view?usp=sharing&resourcekey=0-0adXCXDQEd91ZZW63HbW-Q)).
`gemini extensions install https://github.com/gemini-cli-extensions/datacommons`
- **Model selection:** Choose the Gemini model for your session with `/model`.
([pic](https://imgur.com/a/ABFcWWw),
[pr](https://github.com/google-gemini/gemini-cli/pull/8940) by
[@abhipatel12](https://github.com/abhipatel12)).
- **Model routing:** Gemini CLI will now intelligently pick the best model for
the task. Simple queries will be sent to Flash while complex analytical or
creative tasks will still use the power of Pro. This ensures your quota will
last for a longer period of time. You can always opt-out of this via `/model`.
([pr](https://github.com/google-gemini/gemini-cli/pull/9262) by
[@abhipatel12](https://github.com/abhipatel12)).
- Discussion:
[https://github.com/google-gemini/gemini-cli/discussions/12375](https://github.com/google-gemini/gemini-cli/discussions/12375)
- **Codebase investigator subagent:** We now have a new built-in subagent that
will explore your workspace and resolve relevant information to improve
overall performance.
([pr](https://github.com/google-gemini/gemini-cli/pull/9988) by
[@abhipatel12](https://github.com/abhipatel12),
[pr](https://github.com/google-gemini/gemini-cli/pull/10282) by
[@silviojr](https://github.com/silviojr)).
- Enable, disable, or limit turns in `/settings`, plus advanced configs in
`settings.json` ([pic](https://imgur.com/a/yJiggNO),
[pr](https://github.com/google-gemini/gemini-cli/pull/10844) by
[@silviojr](https://github.com/silviojr)).
- **Explore extensions with `/extension`:** Users can now open the extensions
page in their default browser directly from the CLI using the `/extension`
explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846)
by [@JayadityaGit](https://github.com/JayadityaGit)).
- **Configurable compression:** Users can modify the compression threshold in
`/settings`. The default has been made more proactive
([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by
[@scidomino](https://github.com/scidomino)).
- **API key authentication:** Users can now securely enter and store their
Gemini API key via a new dialog, eliminating the need for environment
variables and repeated entry.
([pr](https://github.com/google-gemini/gemini-cli/pull/11760) by
[@galz10](https://github.com/galz10)).
- **Sequential approval:** Users can now approve multiple tool calls
sequentially during execution.
([pr](https://github.com/google-gemini/gemini-cli/pull/11593) by
[@joshualitt](https://github.com/joshualitt)).
## v0.11.0 - Gemini CLI weekly update - 2025-10-20
![Gemini CLI and Jules](https://storage.googleapis.com/gweb-developer-goog-blog-assets/images/Jules_Extension_-_Blog_Header_O346JNt.original.png)
- 🎉 **Gemini CLI Jules Extension:** Use Gemini CLI to orchestrate Jules. Spawn
remote workers, delegate tedious tasks, or check in on running jobs!
- Install:
`gemini extensions install https://github.com/gemini-cli-extensions/jules`
- Announcement:
[https://developers.googleblog.com/en/introducing-the-jules-extension-for-gemini-cli/](https://developers.googleblog.com/en/introducing-the-jules-extension-for-gemini-cli/)
- **Stream JSON output:** Stream real-time JSONL events with
`--output-format stream-json` to monitor AI agent progress when run
headlessly. ([gif](https://imgur.com/a/0UCE81X),
[pr](https://github.com/google-gemini/gemini-cli/pull/10883) by
[@anj-s](https://github.com/anj-s))
- **Markdown toggle:** Users can now switch between rendered and raw markdown
display using `alt+m `or` ctrl+m`. ([gif](https://imgur.com/a/lDNdLqr),
[pr](https://github.com/google-gemini/gemini-cli/pull/10383) by
[@srivatsj](https://github.com/srivatsj))
- **Queued message editing:** Users can now quickly edit queued messages by
pressing the up arrow key when the input is empty.
([gif](https://imgur.com/a/ioRslLd),
[pr](https://github.com/google-gemini/gemini-cli/pull/10392) by
[@akhil29](https://github.com/akhil29))
- **JSON web fetch**: Non-HTML content like JSON APIs or raw source code are now
properly shown to the model (previously only supported HTML)
([gif](https://imgur.com/a/Q58U4qJ),
[pr](https://github.com/google-gemini/gemini-cli/pull/11284) by
[@abhipatel12](https://github.com/abhipatel12))
- **Non-interactive MCP commands:** Users can now run MCP slash commands in
non-interactive mode `gemini "/some-mcp-prompt"`.
([pr](https://github.com/google-gemini/gemini-cli/pull/10194) by
[@capachino](https://github.com/capachino))
- **Removal of deprecated flags:** Weve finally removed a number of deprecated
flags to cleanup Gemini CLIs invocation profile:
- `--all-files` / `-a` in favor of `@` from within Gemini CLI.
([pr](https://github.com/google-gemini/gemini-cli/pull/11228) by
[@allenhutchison](https://github.com/allenhutchison))
- `--telemetry-*` flags in favor of
[environment variables](https://github.com/google-gemini/gemini-cli/pull/11318)
([pr](https://github.com/google-gemini/gemini-cli/pull/11318) by
[@allenhutchison](https://github.com/allenhutchison))
## v0.10.0 - Gemini CLI weekly update - 2025-10-13
- **Polish:** The team has been heads down bug fixing and investing heavily into
-3
View File
@@ -121,9 +121,6 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Restarts all MCP servers and re-discovers their
available tools.
- [**`/model`**](./model.md)
- **Description:** Opens a dialog to choose your Gemini model.
- **`/memory`**
- **Description:** Manage the AI's instructional context (hierarchical memory
loaded from `GEMINI.md` files).
+17 -36
View File
@@ -1,22 +1,19 @@
# Gemini CLI Keyboard Shortcuts
This document lists the available keyboard shortcuts within Gemini CLI.
This document lists the available keyboard shortcuts in the Gemini CLI.
## General
| Shortcut | Description |
| ----------- | --------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+S` | Toggle copy mode (alternate buffer mode only). |
| `Ctrl+T` | Toggle the display of the todo list. |
| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. |
| `Shift+Tab` | Toggle auto-accepting edits approval mode. |
| `Option+M` | Toggle Markdown rendering for messages (raw markdown mode). |
| `F12` | Toggle the display of the debug console. |
| Shortcut | Description |
| -------- | --------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+T` | Toggle the display of the todo list. |
| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. |
| `F12` | Toggle the display of the debug console. |
## Input Prompt
@@ -35,28 +32,25 @@ This document lists the available keyboard shortcuts within Gemini CLI.
| `Esc` (double press) | Clear the input prompt. |
| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. `Ctrl+F` also toggles focus between input and interactive shell if active. |
| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. |
| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
| `Ctrl+K` | Delete from the cursor to the end of the line. |
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
| `Ctrl+N` | Navigate down through the input history. |
| `Ctrl+P` | Navigate up through the input history. |
| `Ctrl+R` | Activate reverse command search history. |
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. |
| `Ctrl+Z` | Undo last text edit. |
| `Ctrl+Shift+Z` | Redo last undone text edit. |
## Suggestions
| Shortcut | Description |
| ----------------------- | -------------------------------------- |
| `Down Arrow` / `Ctrl+N` | Navigate down through the suggestions. |
| `Tab` / `Enter` | Accept the selected suggestion. |
| `Up Arrow` / `Ctrl+P` | Navigate up through the suggestions. |
| Shortcut | Description |
| --------------- | -------------------------------------- |
| `Down Arrow` | Navigate down through the suggestions. |
| `Tab` / `Enter` | Accept the selected suggestion. |
| `Up Arrow` | Navigate up through the suggestions. |
## Radio Button Select
@@ -74,19 +68,6 @@ This document lists the available keyboard shortcuts within Gemini CLI.
| -------- | --------------------------------- |
| `Ctrl+G` | See context CLI received from IDE |
#### Scrolling
| Action | Keys |
| ------------------------ | -------------------- |
| Scroll content up. | `Shift + Up Arrow` |
| Scroll content down. | `Shift + Down Arrow` |
| Scroll to the top. | `Home` |
| Scroll to the bottom. | `End` |
| Scroll up by one page. | `Page Up` |
| Scroll down by one page. | `Page Down` |
#### History & Search
## Meta+key combos on mac
On Mac, all Meta+char combos should work normally except for these three which
-44
View File
@@ -1,44 +0,0 @@
# 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.
## How to use the `/model` command
Use the following command in Gemini CLI:
```
/model
```
Running this command will open a dialog with your model options:
- **Auto (recommended):** Let the system choose the best model for your task.
Typically, this is the best option.
- **Pro:** For complex tasks that require deep reasoning and creativity. The Pro
model may take longer to return a response.
- **Flash:** For tasks that need a balance of speed and reasoning. The Flash
model will usually return a faster response than Pro.
- **Flash-Lite:** For simple tasks that need to be done quickly. The Flash-Lite
model is typically the fastest.
Changes to these settings will be applied to all subsequent interactions with
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).
- **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.
- **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:
Converting a JSON object to a YAML string.
+16 -24
View File
@@ -72,17 +72,17 @@ observability framework — Gemini CLI's observability system provides:
## Configuration
All telemetry behavior is controlled through your `.gemini/settings.json` file.
Environment variables can be used to override the settings in the file.
These settings can be overridden by environment variables or CLI flags.
| Setting | Environment Variable | Description | Values | Default |
| -------------- | -------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- |
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` |
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
| Setting | Environment Variable | CLI Flag | Description | Values | Default |
| -------------- | -------------------------------- | -------------------------------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- |
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` |
| `target` | `GEMINI_TELEMETRY_TARGET` | `--telemetry-target <local\|gcp>` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint <URL>` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol <grpc\|http>` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | `--telemetry-outfile <path>` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` |
**Note on boolean environment variables:** For the boolean settings (`enabled`,
`logPrompts`, `useCollector`), setting the corresponding environment variable to
@@ -225,9 +225,8 @@ For local development and debugging, you can capture telemetry data locally:
The following section describes the structure of logs and metrics generated for
Gemini CLI.
The `session.id`, `installation.id`, and `user.email` (available only when
authenticated with a Google account) are included as common attributes on all
logs and metrics.
The `session.id`, `installation.id`, and `user.email` are included as common
attributes on all logs and metrics.
### Logs
@@ -252,9 +251,6 @@ Captures startup configuration and user prompt submissions.
- `debug_mode` (boolean)
- `mcp_servers` (string)
- `mcp_servers_count` (int)
- `extensions` (string)
- `extension_ids` (string)
- `extension_count` (int)
- `mcp_tools` (string, if applicable)
- `mcp_tools_count` (int, if applicable)
- `output_format` ("text", "json", or "stream-json")
@@ -282,8 +278,6 @@ Captures tool executions, output truncation, and Smart Edit behavior.
- `prompt_id` (string)
- `tool_type` ("native" or "mcp")
- `mcp_server_name` (string, if applicable)
- `extension_name` (string, if applicable)
- `extension_id` (string, if applicable)
- `content_length` (int, if applicable)
- `metadata` (if applicable)
@@ -545,6 +539,10 @@ Measures tool usage and latency.
- `decision` (string: "accept", "reject", "modify", or "auto_accept", if
applicable)
- `tool_type` (string: "mcp" or "native", if applicable)
- `model_added_lines` (Int, optional)
- `model_removed_lines` (Int, optional)
- `user_added_lines` (Int, optional)
- `user_removed_lines` (Int, optional)
- `gemini_cli.tool.call.latency` (Histogram, ms): Measures tool call latency.
- **Attributes**:
@@ -588,12 +586,6 @@ Counts file operations with basic context.
- `extension` (string, optional)
- `programming_language` (string, optional)
- `gemini_cli.lines.changed` (Counter, Int): Number of lines changed (from file
diffs).
- **Attributes**:
- `function_name`
- `type` ("added" or "removed")
##### Chat and Streaming
Resilience counters for compression, invalid chunks, and retries.
-5
View File
@@ -88,11 +88,6 @@ color keys. For example:
- `DiffRemoved` (optional, for removed lines in diffs)
- `DiffModified` (optional, for modified lines in diffs)
You can also override individual UI text roles by adding a nested `text` object.
This object supports the keys `primary`, `secondary`, `link`, `accent`, and
`response`. When `text.response` is provided it takes precedence over
`text.primary` for rendering model responses in chat.
**Required Properties:**
- `name` (must match the key in the `customThemes` object and be a string)
-2
View File
@@ -11,8 +11,6 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
registered, and used by the core.
- **[Memory Import Processor](./memport.md):** Documentation for the modular
GEMINI.md import feature using @file.md syntax.
- **[Policy Engine](./policy-engine.md):** Use the Policy Engine for
fine-grained control over tool execution.
## Role of the core
-246
View File
@@ -1,246 +0,0 @@
# Policy Engine
:::note This feature is currently in testing. To enable it, set
`tools.enableMessageBusIntegration` to `true` in your `settings.json` file. :::
The Gemini CLI includes a powerful policy engine that provides fine-grained
control over tool execution. It allows users and administrators to define rules
that determine whether a tool call should be allowed, denied, or require user
confirmation.
## Core concepts
The policy engine operates on a set of rules. Each rule is a combination of
conditions and a resulting decision. When a large language model wants to
execute a tool, the policy engine evaluates all rules to find the
highest-priority rule that matches the tool call.
A rule consists of the following main components:
- **Conditions**: Criteria that a tool call must meet for the rule to apply.
This can include the tool's name, the arguments provided to it, or the current
approval mode.
- **Decision**: The action to take if the rule matches (`allow`, `deny`, or
`ask_user`).
- **Priority**: A number that determines the rule's precedence. Higher numbers
win.
For example, this rule will ask for user confirmation before executing any `git`
command.
```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = "git "
decision = "ask_user"
priority = 100
```
### Conditions
Conditions are the criteria that a tool call must meet for a rule to apply. The
primary conditions are the tool's name and its arguments.
#### Tool Name
The `toolName` in the rule must match the name of the tool being called.
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
wildcard. A `toolName` of `my-server__*` will match any tool from the
`my-server` MCP.
#### Arguments Pattern
If `argsPattern` is specified, the tool's arguments are converted to a stable
JSON string, which is then tested against the provided regular expression. If
the arguments don't match the pattern, the rule does not apply.
### Decisions
There are three possible decisions a rule can enforce:
- `allow`: The tool call is executed automatically without user interaction.
- `deny`: The tool call is blocked and is not executed.
- `ask_user`: The user is prompted to approve or deny the tool call. (In
non-interactive mode, this is treated as `deny`.)
### Priority system & tiers
The policy engine uses a sophisticated priority system to resolve conflicts when
multiple rules match a single tool call. The core principle is simple: **the
rule with the highest priority wins**.
To provide a clear hierarchy, policies are organized into three tiers. Each tier
has a designated number that forms the base of the final priority calculation.
| Tier | Base | Description |
| :------ | :--- | :------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with the Gemini CLI. |
| User | 2 | Custom policies defined by the user. |
| Admin | 3 | Policies managed by an administrator (e.g., in an enterprise environment). |
Within a TOML policy file, you assign a priority value from **0 to 999**. The
engine transforms this into a final priority using the following formula:
`final_priority = tier_base + (toml_priority / 1000)`
This system guarantees that:
- Admin policies always override User and Default policies.
- User policies always override Default policies.
- You can still order rules within a single tier with fine-grained control.
For example:
- A `priority: 50` rule in a Default policy file becomes `1.050`.
- A `priority: 100` rule in a User policy file becomes `2.100`.
- A `priority: 20` rule in an Admin policy file becomes `3.020`.
### Approval modes
Approval modes allow the policy engine to apply different sets of rules based on
the CLI's operational mode. A rule can be associated with one or more modes
(e.g., `yolo`, `autoEdit`). The rule will only be active if the CLI is running
in one of its specified modes. If a rule has no modes specified, it is always
active.
## Rule matching
When a tool call is made, the engine checks it against all active rules,
starting from the highest priority. The first rule that matches determines the
outcome.
A rule matches a tool call if all of its conditions are met:
1. **Tool Name**: The `toolName` in the rule must match the name of the tool
being called.
- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a
wildcard. A `toolName` of `my-server__*` will match any tool from the
`my-server` MCP.
2. **Arguments Pattern**: If `argsPattern` is specified, the tool's arguments
are converted to a stable JSON string, which is then tested against the
provided regular expression. If the arguments don't match the pattern, the
rule does not apply.
## Configuration
Policies are defined in `.toml` files. The CLI loads these files from Default,
User, and (if configured) Admin directories.
### TOML rule schema
Here is a breakdown of the fields available in a TOML policy rule:
```toml
[[rule]]
# A unique name for the tool, or an array of names.
toolName = "run_shell_command"
# (Optional) The name of an MCP server. Can be combined with toolName
# to form a composite name like "mcpName__toolName".
mcpName = "my-custom-server"
# (Optional) A regex to match against the tool's arguments.
argsPattern = '"command":"(git|npm)'
# (Optional) A string or array of strings that a shell command must start with.
# This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`.
commandPrefix = "git "
# (Optional) A regex to match against the entire shell command.
# This is also syntactic sugar for `toolName = "run_shell_command"`.
# Note: This pattern is tested against the JSON representation of the arguments (e.g., `{"command":"<your_command>"}`), so anchors like `^` or `$` will apply to the full JSON string, not just the command text.
# You cannot use commandPrefix and commandRegex in the same rule.
commandRegex = "^git (commit|push)"
# The decision to take. Must be "allow", "deny", or "ask_user".
decision = "ask_user"
# The priority of the rule, from 0 to 999.
priority = 10
# (Optional) An array of approval modes where this rule is active.
modes = ["autoEdit"]
```
### Using arrays (lists)
To apply the same rule to multiple tools or command prefixes, you can provide an
array of strings for the `toolName` and `commandPrefix` fields.
**Example:**
This single rule will apply to both the `write_file` and `replace` tools.
```toml
[[rule]]
toolName = ["write_file", "replace"]
decision = "ask_user"
priority = 10
```
### Special syntax for `run_shell_command`
To simplify writing policies for `run_shell_command`, you can use
`commandPrefix` or `commandRegex` instead of the more complex `argsPattern`.
- `commandPrefix`: Matches if the `command` argument starts with the given
string.
- `commandRegex`: Matches if the `command` argument matches the given regular
expression.
**Example:**
This rule will ask for user confirmation before executing any `git` command.
```toml
[[rule]]
toolName = "run_shell_command"
commandPrefix = "git "
decision = "ask_user"
priority = 100
```
### Special syntax for MCP tools
You can create rules that target tools from Model-hosting-protocol (MCP) servers
using the `mcpName` field or a wildcard pattern.
**1. Using `mcpName`**
To target a specific tool from a specific server, combine `mcpName` and
`toolName`.
```toml
# Allows the `search` tool on the `my-jira-server` MCP
[[rule]]
mcpName = "my-jira-server"
toolName = "search"
decision = "allow"
priority = 200
```
**2. Using a Wildcard**
To create a rule that applies to _all_ tools on a specific MCP server, specify
only the `mcpName`.
```toml
# Denies all tools from the `untrusted-server` MCP
[[rule]]
mcpName = "untrusted-server"
decision = "deny"
priority = 500
```
## Default policies
The Gemini CLI ships with a set of default policies to provide a safe
out-of-the-box experience.
- **Read-only tools** (like `read_file`, `glob`) are generally **allowed**.
- **Write tools** (like `write_file`, `run_shell_command`) default to
**`ask_user`**.
- In **`yolo`** mode, a high-priority rule allows all tools.
- In **`autoEdit`** mode, rules allow certain write operations to happen without
prompting.
+2 -1
View File
@@ -62,7 +62,8 @@ The core comes with a suite of pre-defined tools, typically found in
- **File System Tools:**
- `LSTool` (`ls.ts`): Lists directory contents.
- `ReadFileTool` (`read-file.ts`): Reads the content of a single file.
- `ReadFileTool` (`read-file.ts`): Reads the content of a single file. It
takes an `absolute_path` parameter, which must be an absolute path.
- `WriteFileTool` (`write-file.ts`): Writes content to a file.
- `GrepTool` (`grep.ts`): Searches for patterns in files.
- `GlobTool` (`glob.ts`): Finds files matching glob patterns.
+5 -3
View File
@@ -15,9 +15,11 @@ if you need to ship platform specific binary files.
This is the most flexible and simple option. All you need to do is create a
publicly accessible git repo (such as a public github repository) and then users
can install your extension using `gemini extensions install <your-repo-uri>`.
They can optionally depend on a specific ref (branch/tag/commit) using the
`--ref=<some-ref>` argument, this defaults to the default branch.
can install your extension using `gemini extensions install <your-repo-uri>`, or
for a GitHub repository they can use the simplified
`gemini extensions install <org>/<repo>` format. They can optionally depend on a
specific ref (branch/tag/commit) using the `--ref=<some-ref>` argument, this
defaults to the default branch.
Whenever commits are pushed to the ref that a user depends on, they will be
prompted to update the extension. Note that this also allows for easy rollbacks,
+23 -34
View File
@@ -42,19 +42,16 @@ installed on your machine. See
for help.
```
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
gemini extensions install https://github.com/gemini-cli-extensions/security
```
- `<source>`: The github URL or local path of the extension to install.
- `--ref`: The git ref to install from.
- `--auto-update`: Enable auto-update for this extension.
- `--pre-release`: Enable pre-release versions for this extension.
- `--consent`: Acknowledge the security risks of installing an extension and
skip the confirmation prompt.
This will install the Gemini CLI Security extension, which offers support for a
`/security:analyze` command.
### Uninstalling an extension
To uninstall, run `gemini extensions uninstall <name>`:
To uninstall, run `gemini extensions uninstall extension-name`, so, in the case
of the install example:
```
gemini extensions uninstall gemini-cli-security
@@ -65,31 +62,27 @@ gemini extensions uninstall gemini-cli-security
Extensions are, by default, enabled across all workspaces. You can disable an
extension entirely or for specific workspace.
```
gemini extensions disable <name> [--scope <scope>]
```
- `<name>`: The name of the extension to disable.
- `--scope`: The scope to disable the extension in (`user` or `workspace`).
For example, `gemini extensions disable extension-name` will disable the
extension at the user level, so it will be disabled everywhere.
`gemini extensions disable extension-name --scope=workspace` will only disable
the extension in the current workspace.
### Enabling an extension
You can enable extensions using `gemini extensions enable <name>`. You can also
enable an extension for a specific workspace using
`gemini extensions enable <name> --scope=workspace` from within that workspace.
You can enable extensions using `gemini extensions enable extension-name`. You
can also enable an extension for a specific workspace using
`gemini extensions enable extension-name --scope=workspace` from within that
workspace.
```
gemini extensions enable <name> [--scope <scope>]
```
- `<name>`: The name of the extension to enable.
- `--scope`: The scope to enable the extension in (`user` or `workspace`).
This is useful if you have an extension disabled at the top-level and only
enabled in specific places.
### Updating an extension
For extensions installed from a local path or a git repository, you can
explicitly update to the latest version (as reflected in the
`gemini-extension.json` `version` field) with `gemini extensions update <name>`.
`gemini-extension.json` `version` field) with
`gemini extensions update extension-name`.
You can update all extensions with:
@@ -97,6 +90,10 @@ You can update all extensions with:
gemini extensions update --all
```
## Extension creation
We offer commands to make extension development easier.
### Create a boilerplate extension
We offer several example extensions `context`, `custom-commands`,
@@ -107,12 +104,9 @@ To copy one of these examples into a development directory using the type of
your choosing, run:
```
gemini extensions new <path> [template]
gemini extensions new path/to/directory custom-commands
```
- `<path>`: The path to create the extension in.
- `[template]`: The boilerplate template to use.
### Link a local extension
The `gemini extensions link` command will create a symbolic link from the
@@ -122,11 +116,9 @@ This is useful so you don't have to run `gemini extensions update` every time
you make changes you'd like to test.
```
gemini extensions link <path>
gemini extensions link path/to/directory
```
- `<path>`: The path of the extension to link.
## How it works
On startup, Gemini CLI looks for extensions in `<home>/.gemini/extensions`
@@ -187,9 +179,6 @@ precedence.
### Settings
_Note: This is an experimental feature. We do not yet recommend extension
authors introduce settings as part of their core flows._
Extensions can define settings that the user will be prompted to provide upon
installation. This is useful for things like API keys, URLs, or other
configuration that the extension needs to function.
+15
View File
@@ -473,6 +473,21 @@ a few things you can try in order of recommendation:
"loadMemoryFromIncludeDirectories": true
```
- **`chatCompression`** (object):
- **Description:** Controls the settings for chat history compression, both
automatic and when manually invoked through the /compress command.
- **Properties:**
- **`contextPercentageThreshold`** (number): A value between 0 and 1 that
specifies the token threshold for compression as a percentage of the
model's total token limit. For example, a value of `0.6` will trigger
compression when the chat history exceeds 60% of the token limit.
- **Example:**
```json
"chatCompression": {
"contextPercentageThreshold": 0.6
}
```
- **`showLineNumbers`** (boolean):
- **Description:** Controls whether line numbers are displayed in code blocks
in the CLI output.
+121 -245
View File
@@ -36,11 +36,6 @@ overridden by higher numbers):
Gemini CLI uses JSON settings files for persistent configuration. There are four
locations for these files:
> **Tip:** JSON-aware editors can use autocomplete and validation by pointing to
> the generated schema at `schemas/settings.schema.json` in this repository.
> When working outside the repo, reference the hosted schema at
> `https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json`.
- **System defaults file:**
- **Location:** `/etc/gemini-cli/system-defaults.json` (Linux),
`C:\ProgramData\gemini-cli\system-defaults.json` (Windows) or
@@ -94,8 +89,6 @@ contain other project-specific files related to Gemini CLI's operation, such as:
Settings are organized into categories. All settings should be placed within
their corresponding top-level category object in your `settings.json` file.
<!-- SETTINGS-AUTOGEN:START -->
#### `general`
- **`general.preferredEditor`** (string):
@@ -103,11 +96,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **`general.vimMode`** (boolean):
- **Description:** Enable Vim keybindings
- **Description:** Enable Vim keybindings.
- **Default:** `false`
- **`general.disableAutoUpdate`** (boolean):
- **Description:** Disable automatic updates
- **Description:** Disable automatic updates.
- **Default:** `false`
- **`general.disableUpdateNag`** (boolean):
@@ -115,55 +108,34 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **`general.checkpointing.enabled`** (boolean):
- **Description:** Enable session checkpointing for recovery
- **Description:** Enable session checkpointing for recovery.
- **Default:** `false`
- **Requires restart:** Yes
- **`general.enablePromptCompletion`** (boolean):
- **Description:** Enable AI-powered prompt completion suggestions while
typing.
- **Default:** `false`
- **Requires restart:** Yes
- **`general.retryFetchErrors`** (boolean):
- **Description:** Retry on "exception TypeError: fetch failed sending
request" errors.
- **Default:** `false`
- **`general.debugKeystrokeLogging`** (boolean):
- **Description:** Enable debug logging of keystrokes to the console.
- **Default:** `false`
- **`general.sessionRetention.enabled`** (boolean):
- **Description:** Enable automatic session cleanup
- **Description:** Enable automatic session cleanup.
- **Default:** `false`
- **`general.sessionRetention.maxAge`** (string):
- **Description:** Maximum age of sessions to keep (e.g., "30d", "7d", "24h",
"1w")
- **Default:** `undefined`
- **`general.sessionRetention.maxCount`** (number):
- **Description:** Alternative: Maximum number of sessions to keep (most
recent)
- **Default:** `undefined`
- **`general.sessionRetention.minRetention`** (string):
- **Description:** Minimum retention period (safety limit, defaults to "1d")
- **Default:** `"1d"`
#### `output`
- **`output.format`** (enum):
- **`output.format`** (string):
- **Description:** The format of the CLI output.
- **Default:** `"text"`
- **Values:** `"text"`, `"json"`
- **Values:** `"text"`, `"json"`, `"stream-json"`
#### `ui`
- **`ui.theme`** (string):
- **Description:** The color theme for the UI. See the CLI themes guide for
available options.
- **Description:** The color theme for the UI. See [Themes](../cli/themes.md)
for available options.
- **Default:** `undefined`
- **`ui.customThemes`** (object):
@@ -171,21 +143,20 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `{}`
- **`ui.hideWindowTitle`** (boolean):
- **Description:** Hide the window title bar
- **Description:** Hide the window title bar.
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.showStatusInTitle`** (boolean):
- **Description:** Show Gemini CLI status and thoughts in the terminal window
title
title.
- **Default:** `false`
- **`ui.hideTips`** (boolean):
- **Description:** Hide helpful tips in the UI
- **Description:** Hide helpful tips in the UI.
- **Default:** `false`
- **`ui.hideBanner`** (boolean):
- **Description:** Hide the application banner
- **Description:** Hide the application banner.
- **Default:** `false`
- **`ui.hideContextSummary`** (boolean):
@@ -193,6 +164,10 @@ their corresponding top-level category object in your `settings.json` file.
input.
- **Default:** `false`
- **`ui.hideFooter`** (boolean):
- **Description:** Hide the footer from the UI.
- **Default:** `false`
- **`ui.footer.hideCWD`** (boolean):
- **Description:** Hide the current working directory path in the footer.
- **Default:** `false`
@@ -205,16 +180,8 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Hide the model name and context usage in the footer.
- **Default:** `false`
- **`ui.footer.hideContextPercentage`** (boolean):
- **Description:** Hides the context window remaining percentage.
- **Default:** `true`
- **`ui.hideFooter`** (boolean):
- **Description:** Hide the footer from the UI
- **Default:** `false`
- **`ui.showMemoryUsage`** (boolean):
- **Description:** Display memory usage information in the UI
- **Description:** Display memory usage information in the UI.
- **Default:** `false`
- **`ui.showLineNumbers`** (boolean):
@@ -223,47 +190,32 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.showCitations`** (boolean):
- **Description:** Show citations for generated text in the chat.
- **Default:** `false`
- **Default:** `true`
- **`ui.useFullWidth`** (boolean):
- **Description:** Use the entire width of the terminal for output.
- **Default:** `true`
- **`ui.useAlternateBuffer`** (boolean):
- **Description:** Use an alternate screen buffer for the UI, preserving shell
history.
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.incrementalRendering`** (boolean):
- **Description:** Enable incremental rendering for the UI. This option will
reduce flickering but may cause rendering artifacts. Only supported when
useAlternateBuffer is enabled.
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.customWittyPhrases`** (array):
- **Description:** Custom witty phrases to display during loading. When
provided, the CLI cycles through these instead of the defaults.
- **Default:** `[]`
- **Default:** `false`
- **`ui.accessibility.disableLoadingPhrases`** (boolean):
- **Description:** Disable loading phrases for accessibility
- **Description:** Disable loading phrases for accessibility.
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.accessibility.screenReader`** (boolean):
- **Description:** Render output in plain-text to be more screen reader
accessible
- **Description:** Show plaintext interactive view that is more screen reader
friendly.
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.customWittyPhrases`** (array of strings):
- **Description:** A list of custom phrases to display during loading states.
When provided, the CLI will cycle through these phrases instead of the
default ones.
- **Default:** `[]`
#### `ide`
- **`ide.enabled`** (boolean):
- **Description:** Enable IDE integration mode
- **Description:** Enable IDE integration mode.
- **Default:** `false`
- **Requires restart:** Yes
- **`ide.hasSeenNudge`** (boolean):
- **Description:** Whether the user has seen the IDE integration nudge.
@@ -272,9 +224,8 @@ their corresponding top-level category object in your `settings.json` file.
#### `privacy`
- **`privacy.usageStatisticsEnabled`** (boolean):
- **Description:** Enable collection of usage statistics
- **Description:** Enable collection of usage statistics.
- **Default:** `true`
- **Requires restart:** Yes
#### `model`
@@ -288,40 +239,32 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `-1`
- **`model.summarizeToolOutput`** (object):
- **Description:** Enables or disables summarization of tool output. Configure
per-tool token budgets (for example {"run_shell_command": {"tokenBudget":
2000}}). Currently only the run_shell_command tool supports summarization.
- **Description:** Enables or disables the summarization of tool output. You
can specify the token budget for the summarization using the `tokenBudget`
setting. Note: Currently only the `run_shell_command` tool is supported. For
example `{"run_shell_command": {"tokenBudget": 2000}}`
- **Default:** `undefined`
- **`model.compressionThreshold`** (number):
- **Description:** The fraction of context usage at which to trigger context
compression (e.g. 0.2, 0.3).
- **Default:** `0.2`
- **Requires restart:** Yes
- **`model.chatCompression.contextPercentageThreshold`** (number):
- **Description:** Sets the threshold for chat history compression as a
percentage of the model's total token limit. This is a value between 0 and 1
that applies to both automatic compression and the manual `/compress`
command. For example, a value of `0.6` will trigger compression when the
chat history exceeds 60% of the token limit.
- **Default:** `0.7`
- **`model.skipNextSpeakerCheck`** (boolean):
- **Description:** Skip the next speaker check.
- **Default:** `false`
- **`model.enableShellOutputEfficiency`** (boolean):
- **Description:** Optimizes shell tool commands for token efficiency.
- **Default:** `true`
#### `modelConfigs`
- **`modelConfigs.aliases`** (object):
- **Description:** Named presets for model configs. Can be used in place of a
model name and can inherit from other aliases using an `extends` property.
- **Default:**
`{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingBudget":-1}}}},"gemini-2.5-pro":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-pro"}},"gemini-2.5-flash":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash"}},"gemini-2.5-flash-lite":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash-lite"}},"gemini-2.5-flash-base":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash"}},"classifier":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":1024,"thinkingConfig":{"thinkingBudget":512}}}},"prompt-completion":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"temperature":0.3,"maxOutputTokens":16000,"thinkingConfig":{"thinkingBudget":0}}}},"edit-corrector":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"thinkingConfig":{"thinkingBudget":0}}}},"summarizer-default":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"summarizer-shell":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"web-search":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"googleSearch":{}}]}}},"web-fetch":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"urlContext":{}}]}}},"web-fetch-fallback":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"llm-edit-fixer":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"next-speaker-checker":{"extends":"gemini-2.5-flash-base","modelConfig":{}}}`
- **`modelConfigs.overrides`** (array):
- **Description:** Apply specific configuration overrides based on matches,
with a primary key of model (or alias). The most specific match will be
used.
- **Default:** `[]`
#### `context`
- **`context.fileName`** (string | string[]):
- **Description:** The name of the context file or files to load into memory.
Accepts either a single string or an array of strings.
- **`context.fileName`** (string or array of strings):
- **Description:** The name of the context file(s).
- **Default:** `undefined`
- **`context.importFormat`** (string):
@@ -337,51 +280,42 @@ their corresponding top-level category object in your `settings.json` file.
Missing directories will be skipped with a warning.
- **Default:** `[]`
- **`context.loadMemoryFromIncludeDirectories`** (boolean):
- **Description:** Controls how /memory refresh loads GEMINI.md files. When
true, include directories are scanned; when false, only the current
directory is used.
- **`context.loadFromIncludeDirectories`** (boolean):
- **Description:** Controls the behavior of the `/memory refresh` command. If
set to `true`, `GEMINI.md` files should be loaded from all directories that
are added. If set to `false`, `GEMINI.md` should only be loaded from the
current directory.
- **Default:** `false`
- **`context.fileFiltering.respectGitIgnore`** (boolean):
- **Description:** Respect .gitignore files when searching
- **Description:** Respect .gitignore files when searching.
- **Default:** `true`
- **Requires restart:** Yes
- **`context.fileFiltering.respectGeminiIgnore`** (boolean):
- **Description:** Respect .geminiignore files when searching
- **Description:** Respect .geminiignore files when searching.
- **Default:** `true`
- **Requires restart:** Yes
- **`context.fileFiltering.enableRecursiveFileSearch`** (boolean):
- **Description:** Enable recursive file search functionality when completing
@ references in the prompt.
- **Description:** Whether to enable searching recursively for filenames under
the current tree when completing `@` prefixes in the prompt.
- **Default:** `true`
- **Requires restart:** Yes
- **`context.fileFiltering.disableFuzzySearch`** (boolean):
- **Description:** Disable fuzzy search when searching for files.
- **Default:** `false`
- **Requires restart:** Yes
#### `tools`
- **`tools.sandbox`** (boolean | string):
- **Description:** Sandbox execution environment. Set to a boolean to enable
or disable the sandbox, or provide a string path to a sandbox profile.
- **`tools.sandbox`** (boolean or string):
- **Description:** Sandbox execution environment (can be a boolean or a path
string).
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.shell.enableInteractiveShell`** (boolean):
- **Description:** Use node-pty for an interactive shell experience. Fallback
to child_process still applies.
- **Description:** Enables interactive terminal for running shell commands. If
an interactive session cannot be started, it will fall back to a standard
shell.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.shell.pager`** (string):
- **Description:** The pager command to use for shell output. Defaults to
`cat`.
- **Default:** `"cat"`
- **`tools.shell.showColor`** (boolean):
- **Description:** Show color in shell output.
@@ -392,37 +326,42 @@ their corresponding top-level category object in your `settings.json` file.
considered safe (e.g., read-only operations).
- **Default:** `false`
- **`tools.core`** (array):
- **Description:** Restrict the set of built-in tools with an allowlist. Match
semantics mirror tools.allowed; see the built-in tools documentation for
available names.
- **`tools.core`** (array of strings):
- **Description:** This can be used to restrict the set of built-in tools
[with an allowlist](../cli/enterprise.md#restricting-tool-access). See
[Built-in Tools](../core/tools-api.md#built-in-tools) for a list of core
tools. The match semantics are the same as `tools.allowed`.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.allowed`** (array):
- **Description:** Tool names that bypass the confirmation dialog. Useful for
trusted commands (for example ["run_shell_command(git)",
"run_shell_command(npm test)"]). See shell tool command restrictions for
matching details.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.exclude`** (array):
- **`tools.exclude`** (array of strings):
- **Description:** Tool names to exclude from discovery.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.allowed`** (array of strings):
- **Description:** A list of tool names that will bypass the confirmation
dialog. This is useful for tools that you trust and use frequently. For
example, `["run_shell_command(git)", "run_shell_command(npm test)"]` will
skip the confirmation dialog to run any `git` and `npm test` commands. See
[Shell Tool command restrictions](../tools/shell.md#command-restrictions)
for details on prefix matching, command chaining, etc.
- **Default:** `undefined`
- **`tools.discoveryCommand`** (string):
- **Description:** Command to run for tool discovery.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.callCommand`** (string):
- **Description:** Defines a custom shell command for invoking discovered
tools. The command must take the tool name as the first argument, read JSON
arguments from stdin, and emit JSON results on stdout.
- **Description:** Defines a custom shell command for calling a specific tool
that was discovered using `tools.discoveryCommand`. The shell command must
meet the following criteria:
- It must take function `name` (exactly as in
[function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations))
as the first command line argument.
- It must read function arguments as JSON on `stdin`, analogous to
[`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall).
- It must return function output as JSON on `stdout`, analogous to
[`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse).
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.useRipgrep`** (boolean):
- **Description:** Use ripgrep for file content search instead of the fallback
@@ -432,51 +371,36 @@ their corresponding top-level category object in your `settings.json` file.
- **`tools.enableToolOutputTruncation`** (boolean):
- **Description:** Enable truncation of large tool outputs.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.truncateToolOutputThreshold`** (number):
- **Description:** Truncate tool output if it is larger than this many
characters. Set to -1 to disable.
- **Default:** `4000000`
- **Requires restart:** Yes
- **Default:** `20000`
- **`tools.truncateToolOutputLines`** (number):
- **Description:** The number of lines to keep when truncating tool output.
- **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
integration. When enabled, tools will automatically respect policy engine
decisions (ALLOW/DENY/ASK_USER) without requiring individual tool
implementations.
- **Default:** `false`
- **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`
- **Requires restart:** Yes
#### `mcp`
- **`mcp.serverCommand`** (string):
- **Description:** Command to start an MCP server.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`mcp.allowed`** (array):
- **Description:** A list of MCP servers to allow.
- **`mcp.allowed`** (array of strings):
- **Description:** An allowlist of MCP servers to allow.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`mcp.excluded`** (array):
- **Description:** A list of MCP servers to exclude.
- **`mcp.excluded`** (array of strings):
- **Description:** A denylist of MCP servers to exclude.
- **Default:** `undefined`
- **Requires restart:** Yes
#### `useSmartEdit`
@@ -488,54 +412,37 @@ their corresponding top-level category object in your `settings.json` file.
- **`useWriteTodos`** (boolean):
- **Description:** Enable the write_todos tool.
- **Default:** `true`
- **Default:** `false`
#### `security`
- **`security.disableYoloMode`** (boolean):
- **Description:** Disable YOLO mode, even if enabled by a flag.
- **Default:** `false`
- **Requires restart:** Yes
- **`security.blockGitExtensions`** (boolean):
- **Description:** Blocks installing and loading extensions from Git.
- **Default:** `false`
- **Requires restart:** Yes
- **`security.folderTrust.enabled`** (boolean):
- **Description:** Setting to track whether Folder trust is enabled.
- **Default:** `false`
- **Requires restart:** Yes
- **`security.auth.selectedType`** (string):
- **Description:** The currently selected authentication type.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`security.auth.enforcedType`** (string):
- **Description:** The required auth type. If this does not match the selected
auth type, the user will be prompted to re-authenticate.
- **Description:** The required auth type (useful for enterprises).
- **Default:** `undefined`
- **Requires restart:** Yes
- **`security.auth.useExternal`** (boolean):
- **Description:** Whether to use an external authentication flow.
- **Default:** `undefined`
- **Requires restart:** Yes
#### `advanced`
- **`advanced.autoConfigureMemory`** (boolean):
- **Description:** Automatically configure Node.js memory limits
- **Description:** Automatically configure Node.js memory limits.
- **Default:** `false`
- **Requires restart:** Yes
- **`advanced.dnsResolutionOrder`** (string):
- **Description:** The DNS resolution order.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`advanced.excludedEnvVars`** (array):
- **`advanced.excludedEnvVars`** (array of strings):
- **Description:** Environment variables to exclude from project context.
- **Default:** `["DEBUG","DEBUG_MODE"]`
@@ -545,56 +452,10 @@ their corresponding top-level category object in your `settings.json` file.
#### `experimental`
- **`experimental.extensionManagement`** (boolean):
- **Description:** Enable extension management features.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.useModelRouter`** (boolean):
- **Description:** Enable model routing to route requests to the best model
based on complexity.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.enabled`** (boolean):
- **Description:** Enable the Codebase Investigator agent.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.maxNumTurns`** (number):
- **Description:** Maximum number of turns for the Codebase Investigator
agent.
- **Default:** `10`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.maxTimeMinutes`** (number):
- **Description:** Maximum time for the Codebase Investigator agent (in
minutes).
- **Default:** `3`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.thinkingBudget`** (number):
- **Description:** The thinking budget for the Codebase Investigator agent.
- **Default:** `8192`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.model`** (string):
- **Description:** The model to use for the Codebase Investigator agent.
- **Default:** `"gemini-2.5-pro"`
- **Requires restart:** Yes
#### `hooks`
- **`hooks`** (object):
- **Description:** Hook configurations for intercepting and customizing agent
behavior.
- **Default:** `{}`
<!-- SETTINGS-AUTOGEN:END -->
- **Default:** `false`
#### `mcpServers`
@@ -875,11 +736,15 @@ for that specific session.
`--output-format json` or `--output-format stream-json` flag.
- **`--sandbox`** (**`-s`**):
- Enables sandbox mode for this session.
- **`--sandbox-image`**:
- Sets the sandbox image URI.
- **`--debug`** (**`-d`**):
- Enables debug mode for this session, providing more verbose output.
- **`--help`** (or **`-h`**):
- Displays help information about command-line arguments.
- **`--show-memory-usage`**:
- Displays the current memory usage.
- **`--yolo`**:
- Enables YOLO mode, which automatically approves all tool calls.
- **`--approval-mode <mode>`**:
@@ -895,6 +760,22 @@ for that specific session.
- A comma-separated list of tool names that will bypass the confirmation
dialog.
- Example: `gemini --allowed-tools "ShellTool(git status)"`
- **`--telemetry`**:
- Enables [telemetry](../cli/telemetry.md).
- **`--telemetry-target`**:
- Sets the telemetry target. See [telemetry](../cli/telemetry.md) for more
information.
- **`--telemetry-otlp-endpoint`**:
- Sets the OTLP endpoint for telemetry. See [telemetry](../cli/telemetry.md)
for more information.
- **`--telemetry-otlp-protocol`**:
- Sets the OTLP protocol for telemetry (`grpc` or `http`). Defaults to `grpc`.
See [telemetry](../cli/telemetry.md) for more information.
- **`--telemetry-log-prompts`**:
- Enables logging of prompts for telemetry. See
[telemetry](../cli/telemetry.md) for more information.
- **`--checkpointing`**:
- Enables [checkpointing](../cli/checkpointing.md).
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all
available extensions are used.
@@ -902,6 +783,9 @@ for that specific session.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--proxy`**:
- Sets the proxy for the CLI.
- Example: `--proxy http://localhost:7890`.
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory
support.
@@ -914,14 +798,6 @@ for that specific session.
with screen readers.
- **`--version`**:
- Displays the version of the CLI.
- **`--experimental-acp`**:
- Starts the agent in ACP mode.
- **`--allowed-mcp-server-names`**:
- Allowed MCP server names.
- **`--fake-responses`**:
- Path to a file with fake model responses for testing.
- **`--record-responses`**:
- Path to a file to record model responses for testing.
## Context Files (Hierarchical Instructional Context)
-2
View File
@@ -50,8 +50,6 @@ 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.
- **[Policy Engine](./core/policy-engine.md):** Use the Policy Engine for
fine-grained control over tool execution.
### Tools
-3
View File
@@ -72,9 +72,6 @@ these responses.
REGENERATE_MODEL_GOLDENS="true" npm run test:e2e
```
**WARNING**: Make sure you run **await rig.cleanup()** at the end of your test,
else the golden files will not be updated.
### Deflaking a test
Before adding a **new** integration test, you should test it at least 5 times
+6 -8
View File
@@ -8,10 +8,8 @@ account you use to authenticate.
See [privacy and terms](./tos-privacy.md) for details on Privacy policy and
Terms of Service.
> [!NOTE]
>
> Published prices are list price; additional negotiated commercial discounting
> may apply.
> [!NOTE] Published prices are list price; additional negotiated commercial
> discounting may apply.
This article outlines the specific quotas and pricing applicable to the Gemini
CLI when using different authentication methods.
@@ -84,10 +82,10 @@ Gemini CLI by upgrading to one of the following subscriptions:
[Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas)
- [Purchase a Gemini Code Assist Subscription through Google Cloud ](https://cloud.google.com/gemini/docs/codeassist/overview)
by signing up in the Google Cloud console. Learn more at
[Set up Gemini Code Assist](https://cloud.google.com/gemini/docs/discover/set-up-gemini)
Quotas and pricing are based on a fixed price subscription with assigned
license seats. For predictable costs, you can sign in with Google.
by signing up in the Google Cloud console. Learn more at [Set up Gemini Code
Assist] (https://cloud.google.com/gemini/docs/discover/set-up-gemini) Quotas
and pricing are based on a fixed price subscription with assigned license
seats. For predictable costs, you can sign in with Google.
This includes:
- Gemini Code Assist Standard edition:
-8
View File
@@ -72,10 +72,6 @@
"label": "Keyboard Shortcuts",
"slug": "docs/cli/keyboard-shortcuts"
},
{
"label": "Model Selection",
"slug": "docs/cli/model"
},
{
"label": "Sandbox",
"slug": "docs/cli/sandbox"
@@ -120,10 +116,6 @@
{
"label": "Memory Import Processor",
"slug": "docs/core/memport"
},
{
"label": "Policy Engine",
"slug": "docs/core/policy-engine"
}
]
},
+2 -3
View File
@@ -82,9 +82,8 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
from URLs.
- **[Web Search Tool](./web-search.md) (`google_web_search`):** For searching
the web.
- **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** (Deprecated,
will be removed in v0.16.0) A specialized tool for reading content from
multiple files or directories.
- **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** A specialized
tool for reading content from multiple files or directories.
- **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling
information across sessions.
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
+4 -9
View File
@@ -927,13 +927,13 @@ This is the default transport for running local servers.
```bash
# Basic syntax
gemini mcp add [options] <name> <command> [args...]
gemini mcp add <name> <command> [args...]
# Example: Adding a local server
gemini mcp add -e API_KEY=123 -e DEBUG=true my-stdio-server /path/to/server arg1 arg2 arg3
gemini mcp add -e API_KEY=123 my-stdio-server /path/to/server arg1 arg2 arg3
# Example: Adding a local python server
gemini mcp add python-server python server.py -- --server-arg my-value
gemini mcp add python-server python server.py --port 8080
```
#### Adding an HTTP server
@@ -969,8 +969,7 @@ gemini mcp add --transport sse --header "Authorization: Bearer abc123" secure-ss
### Listing Servers (`gemini mcp list`)
To view all MCP servers currently configured, use the `list` command. It
displays each server's name, configuration details, and connection status. This
command has no flags.
displays each server's name, configuration details, and connection status.
**Command:**
@@ -997,10 +996,6 @@ server's name.
gemini mcp remove <name>
```
**Options (Flags):**
- `-s, --scope`: Configuration scope (user or project). [default: "project"]
**Example:**
```bash
-4
View File
@@ -1,9 +1,5 @@
# Multi File Read Tool (`read_many_files`)
> **Deprecated:** This tool is deprecated and will be removed in v0.16.0. Please
> use `read_file` instead. If you need to read multiple files, you can make
> multiple parallel calls to `read_file`.
This document describes the `read_many_files` tool for the Gemini CLI.
## Description
+3 -4
View File
@@ -6,8 +6,7 @@ This document describes the `write_todos` tool for the Gemini CLI.
The `write_todos` tool allows the Gemini agent to create and manage a list of
subtasks for complex user requests. This provides you, the user, with greater
visibility into the agent's plan and its current progress. It also helps with
alignment where the agent is less likely to lose track of its current goal.
visibility into the agent's plan and its current progress.
### Arguments
@@ -50,8 +49,8 @@ write_todos({
## Important notes
- **Enabling:** This tool is enabled by default. You can disable it in your
`settings.json` file by setting `"useWriteTodos": false`.
- **Enabling:** This tool is disabled by default. To use it, you must enable it
in your `settings.json` file by setting `"useWriteTodos": true`.
- **Intended Use:** This tool is primarily used by the agent for complex,
multi-turn tasks. It is generally not used for simple, single-turn questions.
+32 -33
View File
@@ -4,50 +4,49 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { expect, test } from 'vitest';
import { TestRig } from './test-helper.js';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
const extension = `{
"name": "test-extension-install",
"name": "test-extension",
"version": "0.0.1"
}`;
const extensionUpdate = `{
"name": "test-extension-install",
"name": "test-extension",
"version": "0.0.2"
}`;
describe('extension install', () => {
it('installs a local extension, verifies a command, and updates it', async () => {
const rig = new TestRig();
rig.setup('extension install test');
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
writeFileSync(testServerPath, extension);
try {
const result = await rig.runCommand(
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension-install');
test('installs a local extension, verifies a command, and updates it', async () => {
const rig = new TestRig();
rig.setup('extension install test');
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
writeFileSync(testServerPath, extension);
try {
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
} catch {
/* empty */
}
const listResult = await rig.runCommand(['extensions', 'list']);
expect(listResult).toContain('test-extension-install');
writeFileSync(testServerPath, extensionUpdate);
const updateResult = await rig.runCommand([
'extensions',
'update',
`test-extension-install`,
]);
expect(updateResult).toContain('0.0.2');
} finally {
await rig.runCommand([
'extensions',
'uninstall',
'test-extension-install',
]);
await rig.cleanup();
}
});
const result = await rig.runCommand(
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
const listResult = await rig.runCommand(['extensions', 'list']);
expect(listResult).toContain('test-extension');
writeFileSync(testServerPath, extensionUpdate);
const updateResult = await rig.runCommand([
'extensions',
'update',
`test-extension`,
]);
expect(updateResult).toContain('0.0.2');
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
await rig.cleanup();
});
-119
View File
@@ -1,119 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, it, describe } from 'vitest';
import { TestRig } from './test-helper.js';
import { TestMcpServer } from './test-mcp-server.js';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { safeJsonStringify } from '@google/gemini-cli-core/src/utils/safeJsonStringify.js';
import { env } from 'node:process';
import { platform } from 'node:os';
const itIf = (condition: boolean) => (condition ? it : it.skip);
describe('extension reloading', () => {
const sandboxEnv = env['GEMINI_SANDBOX'];
// Fails in sandbox mode, can't check for local extension updates.
itIf((!sandboxEnv || sandboxEnv === 'false') && platform() !== 'win32')(
'installs a local extension, updates it, checks it was reloaded properly',
async () => {
const serverA = new TestMcpServer();
const portA = await serverA.start({
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
const extension = {
name: 'test-extension',
version: '0.0.1',
mcpServers: {
'test-server': {
httpUrl: `http://localhost:${portA}/mcp`,
},
},
};
const rig = new TestRig();
rig.setup('extension reload test', {
settings: {
experimental: { extensionReloading: true },
},
});
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// defensive cleanup from previous tests.
try {
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
} catch {
/* empty */
}
const result = await rig.runCommand(
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
// Now create the update, but its not installed yet
const serverB = new TestMcpServer();
const portB = await serverB.start({
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
extension.version = '0.0.2';
extension.mcpServers['test-server'].httpUrl =
`http://localhost:${portB}/mcp`;
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// Start the CLI.
const run = await rig.runInteractive('--debug');
await run.expectText('You have 1 extension with an update available');
// See the outdated extension
await run.sendText('/extensions list');
await run.type('\r');
await run.expectText(
'test-extension (v0.0.1) - active (update available)',
);
await run.sendText('/mcp list');
await run.type('\r');
await run.expectText(
'test-server (from test-extension) - Ready (1 tool)',
);
await run.expectText('- hello');
// Update the extension, expect the list to update, and mcp servers as well.
await run.sendKeys('/extensions update test-extension');
await run.expectText('/extensions update test-extension');
await run.sendKeys('\r');
await new Promise((resolve) => setTimeout(resolve, 500));
await run.sendKeys('\r');
await run.expectText(
` * test-server (remote): http://localhost:${portB}/mcp`,
);
await run.type('\r'); // consent
await run.expectText(
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
);
await new Promise((resolve) => setTimeout(resolve, 1000));
await run.sendText('/extensions list');
await run.type('\r');
await run.expectText('test-extension (v0.0.2) - active (updated)');
await run.sendText('/mcp list');
await run.type('\r');
await run.expectText(
'test-server (from test-extension) - Ready (1 tool)',
);
await run.expectText('- goodbye');
await run.sendText('/quit');
await run.sendKeys('\r');
// Clean things up.
await serverA.stop();
await serverB.stop();
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
await rig.cleanup();
},
);
});
+14 -25
View File
@@ -98,9 +98,7 @@ describe('file-system', () => {
await rig.setup('should correctly handle file paths with spaces');
const fileName = 'my test file.txt';
const result = await rig.run(
`write "hello" to "${fileName}" and then stop. Do not perform any other actions.`,
);
const result = await rig.run(`write "hello" to "${fileName}"`);
const foundToolCall = await rig.waitForToolCall('write_file');
if (!foundToolCall) {
@@ -152,53 +150,44 @@ describe('file-system', () => {
it.skip('should replace multiple instances of a string', async () => {
const rig = new TestRig();
rig.setup('should replace multiple instances of a string');
await rig.setup('should replace multiple instances of a string');
const fileName = 'ambiguous.txt';
const fileContent = 'Hey there, \ntest line\ntest line';
const expectedContent = 'Hey there, \nnew line\nnew line';
rig.createFile(fileName, fileContent);
const result = await rig.run(
`rewrite the file ${fileName} to replace all instances of "test line" with "new line"`,
`replace "test line" with "new line" in ${fileName}`,
);
const validTools = ['write_file', 'edit'];
const foundToolCall = await rig.waitForAnyToolCall(validTools);
const foundToolCall = await rig.waitForAnyToolCall([
'replace',
'write_file',
]);
if (!foundToolCall) {
printDebugInfo(rig, result, {
'Tool call found': foundToolCall,
'Tool logs': rig.readToolLogs(),
});
printDebugInfo(rig, result);
}
expect(
foundToolCall,
`Expected to find one of ${validTools.join(', ')} tool calls`,
'Expected to find a replace or write_file tool call',
).toBeTruthy();
const toolLogs = rig.readToolLogs();
const successfulEdit = toolLogs.some(
(log) =>
validTools.includes(log.toolRequest.name) && log.toolRequest.success,
(log.toolRequest.name === 'replace' ||
log.toolRequest.name === 'write_file') &&
log.toolRequest.success,
);
if (!successfulEdit) {
console.error(
`Expected a successful edit tool call (${validTools.join(', ')}), but none was found.`,
'Expected a successful edit tool call, but none was found.',
);
printDebugInfo(rig, result);
}
expect(
successfulEdit,
`Expected a successful edit tool call (${validTools.join(', ')})`,
).toBeTruthy();
expect(successfulEdit, 'Expected a successful edit tool call').toBeTruthy();
const newFileContent = rig.readFile(fileName);
if (newFileContent !== expectedContent) {
printDebugInfo(rig, result, {
'Final file content': newFileContent,
'Expected file content': expectedContent,
'Tool logs': rig.readToolLogs(),
});
}
expect(newFileContent).toBe(expectedContent);
});
File diff suppressed because one or more lines are too long
+15 -23
View File
@@ -6,34 +6,26 @@
import { describe, it, expect } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
describe('Flicker Detector', () => {
it('should not detect a flicker under the max height budget', async () => {
// TODO: https://github.com/google-gemini/gemini-cli/issues/11170
it.skip('should not detect a flicker under the max height budget', async () => {
const rig = new TestRig();
rig.setup('flicker-detector-test', {
fakeResponsesPath: join(
import.meta.dirname,
'flicker-detector.max-height.responses',
),
});
try {
const run = await rig.runInteractive();
const prompt = 'Tell me a fun fact.';
await run.type(prompt);
await run.type('\r');
await rig.setup('flicker-detector-test');
const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt');
expect(hasUserPromptEvent).toBe(true);
const run = await rig.runInteractive();
const prompt = 'Tell me a fun fact.';
await run.type(prompt);
await run.type('\r');
const hasSessionCountMetric = await rig.waitForMetric('session.count');
expect(hasSessionCountMetric).toBe(true);
const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt');
expect(hasUserPromptEvent).toBe(true);
// We expect NO flicker event to be found.
const flickerMetric = rig.readMetric('ui.flicker.count');
expect(flickerMetric).toBeNull();
} finally {
await rig.cleanup();
}
const hasSessionCountMetric = await rig.waitForMetric('session.count');
expect(hasSessionCountMetric).toBe(true);
// We expect NO flicker event to be found.
const flickerMetric = rig.readMetric('ui.flicker.count');
expect(flickerMetric).toBeNull();
});
});
-9
View File
@@ -12,7 +12,6 @@ if (process.env['NO_COLOR'] !== undefined) {
import { mkdir, readdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
@@ -33,12 +32,6 @@ export async function setup() {
// construct the path before the HOME env var is set.
process.env['GEMINI_CONFIG_DIR'] = join(runDir, '.gemini');
// Download ripgrep to avoid race conditions in parallel tests
const available = await canUseRipgrep();
if (!available) {
throw new Error('Failed to download ripgrep binary');
}
// Clean up old test runs, but keep the latest few for debugging
try {
const testRuns = await readdir(integrationTestsDir);
@@ -59,8 +52,6 @@ export async function setup() {
process.env['INTEGRATION_TEST_FILE_DIR'] = runDir;
process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true';
// Force file storage to avoid keychain prompts/hangs in CI, especially on macOS
process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true';
process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log');
if (process.env['KEEP_OUTPUT']) {
@@ -1,2 +0,0 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Investigating File Access**\n\nI'm currently focused on the challenge of reading a file. The path provided is `/gemini-cli/.integration-tests/1761766343238/json-output-error/path/to/nonexistent/file.txt`, and I'm anticipating an error. It's safe to assume the file doesn't exist, which I intend to handle by responding with \"File not found\" as instructed.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12303,"totalTokenCount":12418,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12303}],"thoughtsTokenCount":115}},{"candidates":[{"content":{"parts":[{"thought":true,"text":"**Analyzing Error Handling**\n\nI've attempted to read the specified file, expecting an error due to the \"nonexistent\" path. My plan is to catch the error thrown by the `read_file` tool. Upon receiving this error, I'll promptly return \"File not found.\" This is in line with the initial instructions and ensures appropriate error management for the user's intended functionality. I'm now testing the error response.\n\n\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":12303,"totalTokenCount":12467,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12303}],"thoughtsTokenCount":164}},{"candidates":[{"content":{"parts":[{"thoughtSignature":"CiQB0e2Kb0c450IIdZRHl1vvjWDAl9oKa7s5sfFgrTnU0w3qBQwKYgHR7YpvPjZlXSHaJNYgX8IuCvxfyyeACX4NZ8u+u3Z1kqQbgOMpiH6aPYCK9lPyhVftPDBF2m3j7Y2AShwXSpB+9+UB3zphOKCvq6i0ZLvK6QzVynZ1fySQacyjEBD+U6y5CpoBAdHtim9D6oskRu7f3x8rp56h24i6dwb1hzlyqLGl3A5Hsh/fGYjBCxR+Vs+U5Sb7LunmFMKxmO1fktz0x06FUiyaWgAaXl4E4FyLTKs9BbPdgo58uAhaI3vTQybeGZQzkRi6n+ywCniVKaTUd07EHYWdvLiB6x5zFKVOiTmh8PC6I4vNOkJIzFXRfR5aM5QFjEQY8R88HBH+ugraAQHR7YpvuNUG8Ttbzg+kq+kJYMNGDG4zVLPxFBbJZYU5zGLFLrNeEpp9HJ+1N1Pdts17J5pzoefKd2U9H5muPxoeW1CCpFqqKfYZNyoChrQnb4CgWoJ92pcb2UAZbDQ6H4rTyXWdXaAEP1i8rOmdWzI5GFPyxCwBKAbFhfMJOo7P4va5H13XTlLgWAgXlx3n24ookkD/DOL9ro/F6pmV6tzf/j7RVl0V5yozngnrFl4dFsQPp1nDQcFkbRIKHD30byjLGh6Vd6A2lrB477qUaCRfPXtlEgKgsOSYCuABAdHtim+PJPZV3QuQoJ0Qokr+vcdW1W2qzC4Vvt4QRJXyp6+eIS6iNjOisk4jfWLHmLTa11KrUSQsFJnGwsFhe1foOqfO4y46ROBPeSdyuc0gDzSCZPowiovGBwoZd+MbPTTVyExWBo6StVk+xWLj+sUqa8VDof45AOOK9kdzcLQPa2SXIeK6SYLS1zVTrbzGHQDZPv9UC99lmKxPFqAcuwpe9CQre9J3GcZSwd5Mfs/UkHbWui10a8jga4Ck7umjKSDFq/VWiRUp/jh+EN4xeLTG/UQhgH49Gw+Bi1hN1uYKIgHR7YpvS0j1oO2HV/HeK3YxSku9CLQ3OSJGhoAKiu8UGYE=","functionCall":{"name":"read_file","args":{"file_path":"/gemini-cli/.integration-tests/1761766343238/json-output-error/path/to/nonexistent/file.txt"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12303,"candidatesTokenCount":58,"totalTokenCount":12525,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12303}],"thoughtsTokenCount":164}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"File not found"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":12588,"candidatesTokenCount":3,"totalTokenCount":12591,"promptTokensDetails":[{"modality":"TEXT","tokenCount":12588}]}}]}
+1 -8
View File
@@ -6,7 +6,6 @@
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
describe('JSON output', () => {
let rig: TestRig;
@@ -88,13 +87,7 @@ describe('JSON output', () => {
expect(payload.error.message).toContain("current type is 'oauth-personal'");
});
it('should not exit on tool errors and allow model to self-correct in JSON mode', async () => {
rig.setup('json-output-error', {
fakeResponsesPath: join(
import.meta.dirname,
'json-output.error.responses',
),
});
it.skip('should not exit on tool errors and allow model to self-correct in JSON mode', async () => {
const result = await rig.run(
`Read the contents of ${rig.testDir}/path/to/nonexistent/file.txt and tell me what it says. ` +
'On error, respond to the user with exactly the text "File not found".',
+26 -20
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it } from 'vitest';
import { describe, it, expect } from 'vitest';
import {
TestRig,
poll,
@@ -17,7 +17,7 @@ import { join } from 'node:path';
describe('list_directory', () => {
it('should be able to list a directory', async () => {
const rig = new TestRig();
rig.setup('should be able to list a directory');
await rig.setup('should be able to list a directory');
rig.createFile('file1.txt', 'file 1 content');
rig.mkdir('subdir');
rig.sync();
@@ -38,27 +38,33 @@ describe('list_directory', () => {
const result = await rig.run(prompt);
try {
await rig.expectToolCallSuccess(['list_directory']);
} catch (e) {
// Add debugging information
if (!result.includes('file1.txt') || !result.includes('subdir')) {
const allTools = printDebugInfo(rig, result, {
'Found tool call': false,
'Contains file1.txt': result.includes('file1.txt'),
'Contains subdir': result.includes('subdir'),
});
const foundToolCall = await rig.waitForToolCall('list_directory');
console.error(
'List directory calls:',
allTools
.filter((t) => t.toolRequest.name === 'list_directory')
.map((t) => t.toolRequest.args),
);
}
throw e;
// Add debugging information
if (
!foundToolCall ||
!result.includes('file1.txt') ||
!result.includes('subdir')
) {
const allTools = printDebugInfo(rig, result, {
'Found tool call': foundToolCall,
'Contains file1.txt': result.includes('file1.txt'),
'Contains subdir': result.includes('subdir'),
});
console.error(
'List directory calls:',
allTools
.filter((t) => t.toolRequest.name === 'list_directory')
.map((t) => t.toolRequest.args),
);
}
expect(
foundToolCall,
'Expected to find a list_directory tool call',
).toBeTruthy();
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, ['file1.txt', 'subdir'], 'List directory test');
});
@@ -43,6 +43,5 @@ describe('read_many_files', () => {
// Validate model output - will throw if no output
validateModelOutput(result, null, 'Read many files test');
await rig.cleanup();
});
});
-91
View File
@@ -1,91 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
import { Config } from '../packages/core/src/config/config.js';
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
// Mock Config to provide necessary context
class MockConfig {
constructor(private targetDir: string) {}
getTargetDir() {
return this.targetDir;
}
getWorkspaceContext() {
return new WorkspaceContext(this.targetDir, [this.targetDir]);
}
getDebugMode() {
return true;
}
}
describe('ripgrep-real-direct', () => {
let tempDir: string;
let tool: RipGrepTool;
beforeAll(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-real-test-'));
// Create test files
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'hello world\n');
await fs.mkdir(path.join(tempDir, 'subdir'));
await fs.writeFile(
path.join(tempDir, 'subdir', 'file2.txt'),
'hello universe\n',
);
await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n');
const config = new MockConfig(tempDir) as unknown as Config;
tool = new RipGrepTool(config);
});
afterAll(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('should find matches using the real ripgrep binary', async () => {
const invocation = tool.build({ pattern: 'hello' });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 2 matches');
expect(result.llmContent).toContain('file1.txt');
expect(result.llmContent).toContain('L1: hello world');
expect(result.llmContent).toContain('subdir'); // Should show path
expect(result.llmContent).toContain('file2.txt');
expect(result.llmContent).toContain('L1: hello universe');
expect(result.llmContent).not.toContain('goodbye moon');
});
it('should handle no matches correctly', async () => {
const invocation = tool.build({ pattern: 'nonexistent_pattern_123' });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('No matches found');
});
it('should respect include filters', async () => {
// Create a .js file
await fs.writeFile(
path.join(tempDir, 'script.js'),
'console.log("hello");\n',
);
const invocation = tool.build({ pattern: 'hello', include: '*.js' });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 1 match');
expect(result.llmContent).toContain('script.js');
expect(result.llmContent).not.toContain('file1.txt');
});
});
+1 -1
View File
@@ -191,7 +191,7 @@ describe('run_shell_command', () => {
expect(toolCall.toolRequest.success).toBe(true);
});
it.skip('should succeed with no parens in non-interactive mode', async () => {
it('should succeed with no parens in non-interactive mode', async () => {
const rig = new TestRig();
await rig.setup('should succeed with no parens in non-interactive mode');
+5 -19
View File
@@ -192,12 +192,7 @@ export class InteractiveRun {
timeout,
200,
);
expect(
found,
`Did not find expected text: "${text}". Output was:\n${stripAnsi(
this.output,
)}`,
).toBe(true);
expect(found, `Did not find expected text: "${text}"`).toBe(true);
}
// This types slowly to make sure command is correct, but only work for short
@@ -225,13 +220,6 @@ export class InteractiveRun {
}
}
// Types an entire string at once, necessary for some things like commands
// but may run into paste detection issues for larger strings.
async sendText(text: string) {
this.ptyProcess.write(text);
await new Promise((resolve) => setTimeout(resolve, 5));
}
// Simulates typing a string one character at a time to avoid paste detection.
async sendKeys(text: string) {
const delay = 5;
@@ -323,8 +311,6 @@ export class TestRig {
model: DEFAULT_GEMINI_MODEL,
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
// Don't show the IDE connection dialog when running from VsCode
ide: { enabled: false, hasSeenNudge: true },
...options.settings, // Allow tests to override/add settings
};
writeFileSync(
@@ -400,13 +386,13 @@ export class TestRig {
};
if (typeof promptOrOptions === 'string') {
commandArgs.push(promptOrOptions);
commandArgs.push('--prompt', promptOrOptions);
} else if (
typeof promptOrOptions === 'object' &&
promptOrOptions !== null
) {
if (promptOrOptions.prompt) {
commandArgs.push(promptOrOptions.prompt);
commandArgs.push('--prompt', promptOrOptions.prompt);
}
if (promptOrOptions.stdin) {
execOptions.input = promptOrOptions.stdin;
@@ -1009,7 +995,7 @@ export class TestRig {
const options: pty.IPtyForkOptions = {
name: 'xterm-color',
cols: 80,
rows: 80,
rows: 24,
cwd: this.testDir!,
env: Object.fromEntries(
Object.entries(env).filter(([, v]) => v !== undefined),
@@ -1021,7 +1007,7 @@ export class TestRig {
const run = new InteractiveRun(ptyProcess);
// Wait for the app to be ready
await run.expectText(' Type your message or @path/to/file', 30000);
await run.expectText('Type your message', 30000);
return run;
}
}
+10 -26
View File
@@ -4,21 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
McpServer,
type ToolCallback,
} from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { type Server as HTTPServer } from 'node:http';
import { type ZodRawShape } from 'zod';
import { randomUUID } from 'node:crypto';
export class TestMcpServer {
private server: HTTPServer | undefined;
async start(
tools?: Record<string, ToolCallback<ZodRawShape>>,
): Promise<number> {
async start(): Promise<number> {
const app = express();
app.use(express.json());
const mcpServer = new McpServer(
@@ -26,30 +22,18 @@ export class TestMcpServer {
name: 'test-mcp-server',
version: '1.0.0',
},
{ capabilities: { tools: {} } },
{ capabilities: {} },
);
if (tools) {
for (const [name, cb] of Object.entries(tools)) {
mcpServer.registerTool(name, {}, cb);
}
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
mcpServer.connect(transport);
app.post('/mcp', async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on('close', () => {
transport.close();
});
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.get('/mcp', async (req, res) => {
res.status(405).send('Not supported');
});
return new Promise((resolve, reject) => {
this.server = app.listen(0, () => {
const address = this.server!.address();
+593 -118
View File
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.15.4",
"version": "0.12.0-preview.3",
"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.15.4"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.12.0-preview.3"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -27,9 +27,6 @@
"auth:docker": "gcloud auth configure-docker us-west1-docker.pkg.dev",
"auth": "npm run auth:npm && npm run auth:docker",
"generate": "node scripts/generate-git-commit-info.js",
"predocs:settings": "npm run build --workspace @google/gemini-cli-core",
"schema:settings": "tsx ./scripts/generate-settings-schema.ts",
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
"build": "node scripts/build.js",
"build-and-start": "npm run build && npm run start",
"build:vscode": "node scripts/build_vscode_companion.js",
@@ -61,7 +58,6 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.3",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -82,8 +78,6 @@
"@types/minimatch": "^5.1.2",
"@types/mock-fs": "^4.13.4",
"@types/prompts": "^2.4.9",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/shell-quote": "^1.7.5",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4",
@@ -100,7 +94,6 @@
"globals": "^16.0.0",
"google-artifactregistry-auth": "^3.4.0",
"husky": "^9.1.7",
"ink-testing-library": "^4.0.0",
"json": "^11.0.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
@@ -109,7 +102,7 @@
"msw": "^2.10.4",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"react-devtools-core": "^6.1.2",
"react-devtools-core": "^4.28.5",
"semver": "^7.7.2",
"strip-ansi": "^7.1.2",
"tsx": "^4.20.3",
@@ -118,7 +111,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.3",
"@testing-library/dom": "^10.4.1",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.15.4",
"version": "0.12.0-preview.3",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -30,7 +30,7 @@
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"fs-extra": "^11.3.0",
"tar": "^7.5.2",
"tar": "^7.4.3",
"uuid": "^11.1.0",
"winston": "^3.17.0"
},
+1 -1
View File
@@ -113,7 +113,7 @@ export class Task {
// state managed within the @gemini-cli/core module.
async getMetadata(): Promise<TaskMetadata> {
const toolRegistry = await this.config.getToolRegistry();
const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {};
const mcpServers = this.config.getMcpServers() || {};
const serverStatuses = getAllMCPServerStatuses();
const servers = Object.keys(mcpServers).map((serverName) => ({
name: serverName,
@@ -5,44 +5,27 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Command } from './types.js';
describe('CommandRegistry', () => {
const mockListExtensionsCommandInstance: Command = {
name: 'extensions list',
description: 'Lists all installed extensions.',
const mockListExtensionsCommandInstance = {
names: ['extensions', 'extensions list'],
execute: vi.fn(),
};
const mockListExtensionsCommand = vi.fn(
() => mockListExtensionsCommandInstance,
);
const mockExtensionsCommandInstance: Command = {
name: 'extensions',
description: 'Manage extensions.',
execute: vi.fn(),
subCommands: [mockListExtensionsCommandInstance],
};
const mockExtensionsCommand = vi.fn(() => mockExtensionsCommandInstance);
beforeEach(async () => {
vi.resetModules();
vi.doMock('./extensions.js', () => ({
ExtensionsCommand: mockExtensionsCommand,
vi.doMock('./list-extensions', () => ({
ListExtensionsCommand: mockListExtensionsCommand,
}));
});
it('should register ExtensionsCommand on initialization', async () => {
it('should register ListExtensionsCommand on initialization', async () => {
const { commandRegistry } = await import('./command-registry.js');
expect(mockExtensionsCommand).toHaveBeenCalled();
expect(mockListExtensionsCommand).toHaveBeenCalled();
const command = commandRegistry.get('extensions');
expect(command).toBe(mockExtensionsCommandInstance);
});
it('should register sub commands on initialization', async () => {
const { commandRegistry } = await import('./command-registry.js');
const command = commandRegistry.get('extensions list');
expect(command).toBe(mockListExtensionsCommandInstance);
});
@@ -54,65 +37,12 @@ describe('CommandRegistry', () => {
it('register() should register a new command', async () => {
const { commandRegistry } = await import('./command-registry.js');
const mockCommand: Command = {
name: 'test-command',
description: '',
const mockCommand = {
names: ['test-command'],
execute: vi.fn(),
};
commandRegistry.register(mockCommand);
const command = commandRegistry.get('test-command');
expect(command).toBe(mockCommand);
});
it('register() should register a nested command', async () => {
const { commandRegistry } = await import('./command-registry.js');
const mockSubSubCommand: Command = {
name: 'test-command-sub-sub',
description: '',
execute: vi.fn(),
};
const mockSubCommand: Command = {
name: 'test-command-sub',
description: '',
execute: vi.fn(),
subCommands: [mockSubSubCommand],
};
const mockCommand: Command = {
name: 'test-command',
description: '',
execute: vi.fn(),
subCommands: [mockSubCommand],
};
commandRegistry.register(mockCommand);
const command = commandRegistry.get('test-command');
const subCommand = commandRegistry.get('test-command-sub');
const subSubCommand = commandRegistry.get('test-command-sub-sub');
expect(command).toBe(mockCommand);
expect(subCommand).toBe(mockSubCommand);
expect(subSubCommand).toBe(mockSubSubCommand);
});
it('register() should not enter an infinite loop with a cyclic command', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { commandRegistry } = await import('./command-registry.js');
const mockCommand: Command = {
name: 'cyclic-command',
description: '',
subCommands: [],
execute: vi.fn(),
};
mockCommand.subCommands?.push(mockCommand); // Create cycle
commandRegistry.register(mockCommand);
expect(commandRegistry.get('cyclic-command')).toBe(mockCommand);
expect(warnSpy).toHaveBeenCalledWith(
'Command cyclic-command already registered. Skipping.',
);
// If the test finishes, it means we didn't get into an infinite loop.
warnSpy.mockRestore();
});
});
@@ -4,36 +4,30 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ExtensionsCommand } from './extensions.js';
import type { Command } from './types.js';
import { ListExtensionsCommand } from './list-extensions.js';
import type { Config } from '@google/gemini-cli-core';
export interface Command {
readonly names: string[];
execute(config: Config, args: string[]): Promise<unknown>;
}
class CommandRegistry {
private readonly commands = new Map<string, Command>();
constructor() {
this.register(new ExtensionsCommand());
this.register(new ListExtensionsCommand());
}
register(command: Command) {
if (this.commands.has(command.name)) {
console.warn(`Command ${command.name} already registered. Skipping.`);
return;
}
this.commands.set(command.name, command);
for (const subCommand of command.subCommands ?? []) {
this.register(subCommand);
for (const name of command.names) {
this.commands.set(name, command);
}
}
get(commandName: string): Command | undefined {
return this.commands.get(commandName);
}
getAllCommands(): Command[] {
return [...this.commands.values()];
}
}
export const commandRegistry = new CommandRegistry();
@@ -1,87 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { ExtensionsCommand, ListExtensionsCommand } from './extensions.js';
import type { Config } from '@google/gemini-cli-core';
const mockListExtensions = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
listExtensions: mockListExtensions,
};
});
describe('ExtensionsCommand', () => {
it('should have the correct name', () => {
const command = new ExtensionsCommand();
expect(command.name).toEqual('extensions');
});
it('should have the correct description', () => {
const command = new ExtensionsCommand();
expect(command.description).toEqual('Manage extensions.');
});
it('should have "extensions list" as a subcommand', () => {
const command = new ExtensionsCommand();
expect(command.subCommands.map((c) => c.name)).toContain('extensions list');
});
it('should be a top-level command', () => {
const command = new ExtensionsCommand();
expect(command.topLevel).toBe(true);
});
it('should default to listing extensions', async () => {
const command = new ExtensionsCommand();
const mockConfig = {} as Config;
const mockExtensions = [{ name: 'ext1' }];
mockListExtensions.mockReturnValue(mockExtensions);
const result = await command.execute(mockConfig, []);
expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
});
});
describe('ListExtensionsCommand', () => {
it('should have the correct name', () => {
const command = new ListExtensionsCommand();
expect(command.name).toEqual('extensions list');
});
it('should call listExtensions with the provided config', async () => {
const command = new ListExtensionsCommand();
const mockConfig = {} as Config;
const mockExtensions = [{ name: 'ext1' }];
mockListExtensions.mockReturnValue(mockExtensions);
const result = await command.execute(mockConfig, []);
expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
});
it('should return a message when no extensions are installed', async () => {
const command = new ListExtensionsCommand();
const mockConfig = {} as Config;
mockListExtensions.mockReturnValue([]);
const result = await command.execute(mockConfig, []);
expect(result).toEqual({
name: 'extensions list',
data: 'No extensions installed.',
});
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
});
});
@@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { listExtensions, type Config } from '@google/gemini-cli-core';
import type { Command, CommandExecutionResponse } from './types.js';
export class ExtensionsCommand implements Command {
readonly name = 'extensions';
readonly description = 'Manage extensions.';
readonly subCommands = [new ListExtensionsCommand()];
readonly topLevel = true;
async execute(
config: Config,
_: string[],
): Promise<CommandExecutionResponse> {
return new ListExtensionsCommand().execute(config, _);
}
}
export class ListExtensionsCommand implements Command {
readonly name = 'extensions list';
readonly description = 'Lists all installed extensions.';
async execute(
config: Config,
_: string[],
): Promise<CommandExecutionResponse> {
const extensions = listExtensions(config);
const data = extensions.length ? extensions : 'No extensions installed.';
return { name: this.name, data };
}
}
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { ListExtensionsCommand } from './list-extensions.js';
import type { Config } from '@google/gemini-cli-core';
const mockListExtensions = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
listExtensions: mockListExtensions,
};
});
describe('ListExtensionsCommand', () => {
it('should have the correct names', () => {
const command = new ListExtensionsCommand();
expect(command.names).toEqual(['extensions', 'extensions list']);
});
it('should call listExtensions with the provided config', async () => {
const command = new ListExtensionsCommand();
const mockConfig = {} as Config;
const mockExtensions = [{ name: 'ext1' }];
mockListExtensions.mockReturnValue(mockExtensions);
const result = await command.execute(mockConfig, []);
expect(result).toEqual(mockExtensions);
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
});
});
@@ -0,0 +1,16 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { listExtensions, type Config } from '@google/gemini-cli-core';
import type { Command } from './command-registry.js';
export class ListExtensionsCommand implements Command {
readonly names = ['extensions', 'extensions list'];
async execute(config: Config, _: string[]): Promise<unknown> {
return listExtensions(config);
}
}
-28
View File
@@ -1,28 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@google/gemini-cli-core';
export interface CommandArgument {
readonly name: string;
readonly description: string;
readonly isRequired?: boolean;
}
export interface Command {
readonly name: string;
readonly description: string;
readonly arguments?: CommandArgument[];
readonly subCommands?: Command[];
readonly topLevel?: boolean;
execute(config: Config, args: string[]): Promise<CommandExecutionResponse>;
}
export interface CommandExecutionResponse {
readonly name: string;
readonly data: unknown;
}
+23 -1
View File
@@ -20,7 +20,9 @@ import {
GEMINI_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_GEMINI_MODEL,
type GeminiCLIExtension,
type ExtensionLoader,
debugLogger,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
@@ -32,6 +34,7 @@ export async function loadConfig(
extensionLoader: ExtensionLoader,
taskId: string,
): Promise<Config> {
const mcpServers = mergeMcpServers(settings, extensionLoader.getExtensions());
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
@@ -51,7 +54,7 @@ export async function loadConfig(
process.env['GEMINI_YOLO_MODE'] === 'true'
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT,
mcpServers: settings.mcpServers,
mcpServers,
cwd: workspaceDir,
telemetry: {
enabled: settings.telemetry?.enabled,
@@ -117,6 +120,25 @@ export async function loadConfig(
return config;
}
export function mergeMcpServers(
settings: Settings,
extensions: GeminiCLIExtension[],
) {
const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
if (mcpServers[key]) {
debugLogger.warn(
`Skipping extension MCP config for server with key "${key}" as it already exists.`,
);
return;
}
mcpServers[key] = server;
});
}
return mcpServers;
}
export function setTargetDir(agentSettings: AgentSettings | undefined): string {
const originalCWD = process.cwd();
const targetDir =
+1 -104
View File
@@ -28,7 +28,6 @@ import {
vi,
} from 'vitest';
import { createApp } from './app.js';
import { commandRegistry } from '../commands/command-registry.js';
import {
assertUniqueFinalEventIsLast,
assertTaskCreationAndWorkingStatus,
@@ -36,7 +35,6 @@ import {
createMockConfig,
} from '../utils/testing_utils.js';
import { MockTool } from '@google/gemini-cli-core';
import type { Command } from '../commands/types.js';
const mockToolConfirmationFn = async () =>
({}) as unknown as ToolCallConfirmationDetails;
@@ -829,104 +827,6 @@ describe('E2E Tests', () => {
expect(thoughtEvent.metadata?.['traceId']).toBe(traceId);
});
describe('/listCommands', () => {
it('should return a list of top-level commands', async () => {
const mockCommands = [
{
name: 'test-command',
description: 'A test command',
topLevel: true,
arguments: [{ name: 'arg1', description: 'Argument 1' }],
subCommands: [
{
name: 'sub-command',
description: 'A sub command',
topLevel: false,
execute: vi.fn(),
},
],
execute: vi.fn(),
},
{
name: 'another-command',
description: 'Another test command',
topLevel: true,
execute: vi.fn(),
},
{
name: 'not-top-level',
description: 'Not a top level command',
topLevel: false,
execute: vi.fn(),
},
];
const getAllCommandsSpy = vi
.spyOn(commandRegistry, 'getAllCommands')
.mockReturnValue(mockCommands);
const agent = request.agent(app);
const res = await agent.get('/listCommands').expect(200);
expect(res.body).toEqual({
commands: [
{
name: 'test-command',
description: 'A test command',
arguments: [{ name: 'arg1', description: 'Argument 1' }],
subCommands: [
{
name: 'sub-command',
description: 'A sub command',
arguments: [],
subCommands: [],
},
],
},
{
name: 'another-command',
description: 'Another test command',
arguments: [],
subCommands: [],
},
],
});
expect(getAllCommandsSpy).toHaveBeenCalledOnce();
getAllCommandsSpy.mockRestore();
});
it('should handle cyclic commands gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const cyclicCommand: Command = {
name: 'cyclic-command',
description: 'A cyclic command',
topLevel: true,
execute: vi.fn(),
subCommands: [],
};
cyclicCommand.subCommands?.push(cyclicCommand); // Create cycle
const getAllCommandsSpy = vi
.spyOn(commandRegistry, 'getAllCommands')
.mockReturnValue([cyclicCommand]);
const agent = request.agent(app);
const res = await agent.get('/listCommands').expect(200);
expect(res.body.commands[0].name).toBe('cyclic-command');
expect(res.body.commands[0].subCommands).toEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
'Command cyclic-command already inserted in the response, skipping',
);
getAllCommandsSpy.mockRestore();
warnSpy.mockRestore();
});
});
describe('/executeCommand', () => {
const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }];
@@ -946,10 +846,7 @@ describe('E2E Tests', () => {
.set('Content-Type', 'application/json')
.expect(200);
expect(res.body).toEqual({
name: 'extensions list',
data: mockExtensions,
});
expect(res.body).toEqual(mockExtensions);
expect(getExtensionsSpy).toHaveBeenCalled();
});
-50
View File
@@ -21,14 +21,6 @@ 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 type { Command, CommandArgument } from '../commands/types.js';
type CommandResponse = {
name: string;
description: string;
arguments: CommandArgument[];
subCommands: CommandResponse[];
};
const coderAgentCard: AgentCard = {
name: 'Gemini SDLC Agent',
@@ -175,48 +167,6 @@ export async function createApp() {
}
});
expressApp.get('/listCommands', (req, res) => {
try {
const transformCommand = (
command: Command,
visited: string[],
): CommandResponse | undefined => {
const commandName = command.name;
if (visited.includes(commandName)) {
console.warn(
`Command ${commandName} already inserted in the response, skipping`,
);
return undefined;
}
return {
name: command.name,
description: command.description,
arguments: command.arguments ?? [],
subCommands: (command.subCommands ?? [])
.map((subCommand) =>
transformCommand(subCommand, visited.concat(commandName)),
)
.filter(
(subCommand): subCommand is CommandResponse => !!subCommand,
),
};
};
const commands = commandRegistry
.getAllCommands()
.filter((command) => command.topLevel)
.map((command) => transformCommand(command, []));
return res.status(200).json({ commands });
} catch (e) {
logger.error('Error executing /listCommands:', e);
const errorMessage =
e instanceof Error ? e.message : 'Unknown error listing commands';
return res.status(500).json({ error: errorMessage });
}
});
expressApp.get('/tasks/metadata', async (req, res) => {
// This endpoint is only meaningful if the task store is in-memory.
if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) {
@@ -53,7 +53,6 @@ export function createMockConfig(
getEnableMessageBusIntegration: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn(),
getPolicyEngine: vi.fn(),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Config;
+1
View File
@@ -11,6 +11,7 @@ export default defineConfig({
test: {
include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'],
exclude: ['**/node_modules/**', '**/dist/**'],
environment: 'jsdom',
globals: true,
reporters: ['default', 'junit'],
silent: true,
@@ -1,165 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef } from 'react';
import { render, Box, Text, useInput, useStdout } from 'ink';
import {
ScrollableList,
type ScrollableListRef,
} from '../src/ui/components/shared/ScrollableList.js';
import { ScrollProvider } from '../src/ui/contexts/ScrollProvider.js';
import { MouseProvider } from '../src/ui/contexts/MouseContext.js';
import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
import {
enableMouseEvents,
disableMouseEvents,
} from '../src/ui/utils/mouse.js';
interface Item {
id: string;
title: string;
}
const getLorem = (index: number) =>
Array(10)
.fill(null)
.map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim())
.join('\n');
const Demo = () => {
const { stdout } = useStdout();
const [size, setSize] = useState({
columns: stdout.columns,
rows: stdout.rows,
});
useEffect(() => {
const onResize = () => {
setSize({
columns: stdout.columns,
rows: stdout.rows,
});
};
stdout.on('resize', onResize);
return () => {
stdout.off('resize', onResize);
};
}, [stdout]);
const [items, setItems] = useState<Item[]>(() =>
Array.from({ length: 1000 }, (_, i) => ({
id: String(i),
title: `Item ${i + 1}`,
})),
);
const listRef = useRef<ScrollableListRef<Item>>(null);
useInput((input, key) => {
if (input === 'a' || input === 'A') {
setItems((prev) => [
...prev,
{ id: String(prev.length), title: `Item ${prev.length + 1}` },
]);
}
if ((input === 'e' || input === 'E') && !key.ctrl) {
setItems((prev) => {
if (prev.length === 0) return prev;
const lastIndex = prev.length - 1;
const lastItem = prev[lastIndex]!;
const newItem = { ...lastItem, title: lastItem.title + 'e' };
return [...prev.slice(0, lastIndex), newItem];
});
}
if (key.ctrl && input === 'e') {
listRef.current?.scrollToEnd();
}
// Let Ink handle Ctrl+C via exitOnCtrlC (default true) or handle explicitly if needed.
// For alternate buffer, explicit handling is often safer for cleanup.
if (key.escape || (key.ctrl && input === 'c')) {
process.exit(0);
}
});
return (
<MouseProvider mouseEventsEnabled={true}>
<KeypressProvider>
<ScrollProvider>
<Box
flexDirection="column"
width={size.columns}
height={size.rows - 1}
padding={1}
>
<Text>
Press &apos;A&apos; to add an item. Press &apos;E&apos; to edit
last item. Press &apos;Ctrl+E&apos; to scroll to end. Press
&apos;Esc&apos; to exit. Mouse wheel or Shift+Up/Down to scroll.
</Text>
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
<ScrollableList
ref={listRef}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" paddingBottom={2}>
<Box
sticky
flexDirection="column"
width={size.columns - 2}
opaque
stickyChildren={
<Box
flexDirection="column"
width={size.columns - 2}
opaque
>
<Text>{item.title}</Text>
<Box
borderStyle="single"
borderTop={true}
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor="gray"
/>
</Box>
}
>
<Text>{item.title}</Text>
</Box>
<Text color="gray">{getLorem(index)}</Text>
</Box>
)}
estimatedItemHeight={() => 14}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
initialScrollOffsetInIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
<Text>Count: {items.length}</Text>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
// Enable mouse reporting before rendering
enableMouseEvents();
// Ensure cleanup happens on exit
process.on('exit', () => {
disableMouseEvents();
});
// Handle SIGINT explicitly to ensure cleanup runs if Ink doesn't catch it in time
process.on('SIGINT', () => {
process.exit(0);
});
render(<Demo />, { alternateBuffer: true });
+11 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.15.4",
"version": "0.12.0-preview.3",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.4"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.12.0-preview.3"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -42,7 +42,7 @@
"fzf": "^0.5.2",
"glob": "^10.4.5",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.3",
"ink": "^6.2.3",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -50,15 +50,14 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"prompts": "^2.4.2",
"react": "^19.2.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"string-width": "^8.1.0",
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"tinygradient": "^1.1.5",
"tar": "^7.5.1",
"undici": "^7.10.0",
"wrap-ansi": "9.0.2",
"yargs": "^17.7.2",
@@ -67,21 +66,23 @@
"devDependencies": {
"@babel/runtime": "^7.27.6",
"@google/gemini-cli-test-utils": "file:../test-utils",
"@testing-library/react": "^16.3.0",
"@types/archiver": "^6.0.3",
"@types/command-exists": "^1.2.3",
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/node": "^20.11.24",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
"@types/tar": "^6.1.13",
"@types/yargs": "^17.0.32",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",
"jsdom": "^26.1.0",
"pretty-format": "^30.0.2",
"react-dom": "^19.2.0",
"react-dom": "^19.1.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
-2
View File
@@ -13,7 +13,6 @@ import { disableCommand } from './extensions/disable.js';
import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
import { validateCommand } from './extensions/validate.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -29,7 +28,6 @@ export const extensionsCommand: CommandModule = {
.command(enableCommand)
.command(linkCommand)
.command(newCommand)
.command(validateCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
@@ -4,22 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, type MockInstance, type Mock } from 'vitest';
import { describe, it, expect, vi, type MockInstance } 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 { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
const mockInstallOrUpdateExtension: Mock<
typeof ExtensionManager.prototype.installOrUpdateExtension
> = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInstallOrUpdateExtension = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockStat = vi.hoisted(() => vi.fn());
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
@@ -58,13 +49,13 @@ describe('extensions install command', () => {
});
describe('handleInstall', () => {
let debugLogSpy: MockInstance;
let debugErrorSpy: MockInstance;
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let processSpy: MockInstance;
beforeEach(() => {
debugLogSpy = vi.spyOn(debugLogger, 'log');
debugErrorSpy = vi.spyOn(debugLogger, 'error');
consoleLogSpy = vi.spyOn(console, 'log');
consoleErrorSpy = vi.spyOn(console, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
@@ -78,43 +69,37 @@ describe('handleInstall', () => {
});
it('should install an extension from a http source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'http-extension',
} as unknown as GeminiCLIExtension);
mockInstallOrUpdateExtension.mockResolvedValue('http-extension');
await handleInstall({
source: 'http://google.com',
});
expect(debugLogSpy).toHaveBeenCalledWith(
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "http-extension" installed successfully and enabled.',
);
});
it('should install an extension from a https source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'https-extension',
} as unknown as GeminiCLIExtension);
mockInstallOrUpdateExtension.mockResolvedValue('https-extension');
await handleInstall({
source: 'https://google.com',
});
expect(debugLogSpy).toHaveBeenCalledWith(
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "https-extension" installed successfully and enabled.',
);
});
it('should install an extension from a git source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'git-extension',
} as unknown as GeminiCLIExtension);
mockInstallOrUpdateExtension.mockResolvedValue('git-extension');
await handleInstall({
source: 'git@some-url',
});
expect(debugLogSpy).toHaveBeenCalledWith(
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "git-extension" installed successfully and enabled.',
);
});
@@ -125,34 +110,30 @@ describe('handleInstall', () => {
source: 'test://google.com',
});
expect(debugErrorSpy).toHaveBeenCalledWith('Install source not found.');
expect(consoleErrorSpy).toHaveBeenCalledWith('Install source not found.');
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should install an extension from a sso source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'sso-extension',
} as unknown as GeminiCLIExtension);
mockInstallOrUpdateExtension.mockResolvedValue('sso-extension');
await handleInstall({
source: 'sso://google.com',
});
expect(debugLogSpy).toHaveBeenCalledWith(
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "sso-extension" installed successfully and enabled.',
);
});
it('should install an extension from a local path', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'local-extension',
} as unknown as GeminiCLIExtension);
mockStat.mockResolvedValue({} as Stats);
mockInstallOrUpdateExtension.mockResolvedValue('local-extension');
mockStat.mockResolvedValue({});
await handleInstall({
source: '/some/path',
});
expect(debugLogSpy).toHaveBeenCalledWith(
expect(consoleLogSpy).toHaveBeenCalledWith(
'Extension "local-extension" installed successfully and enabled.',
);
});
@@ -164,7 +145,7 @@ describe('handleInstall', () => {
await handleInstall({ source: 'git@some-url' });
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
expect(consoleErrorSpy).toHaveBeenCalledWith('Install extension failed');
expect(processSpy).toHaveBeenCalledWith(1);
});
});
@@ -77,11 +77,9 @@ export async function handleInstall(args: InstallArgs) {
settings: loadSettings(workspaceDir).merged,
});
await extensionManager.loadExtensions();
const extension =
const name =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(
`Extension "${extension.name}" installed successfully and enabled.`,
);
debugLogger.log(`Extension "${name}" installed successfully and enabled.`);
} catch (error) {
debugLogger.error(getErrorMessage(error));
process.exit(1);
+2 -2
View File
@@ -34,10 +34,10 @@ export async function handleLink(args: InstallArgs) {
settings: loadSettings(workspaceDir).merged,
});
await extensionManager.loadExtensions();
const extension =
const extensionName =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(
`Extension "${extension.name}" linked successfully and enabled.`,
`Extension "${extensionName}" linked successfully and enabled.`,
);
} catch (error) {
debugLogger.error(getErrorMessage(error));
@@ -30,12 +30,11 @@ const updateOutput = (info: ExtensionUpdateInfo) =>
export async function handleUpdate(args: UpdateArgs) {
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir).merged;
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
settings,
settings: loadSettings(workspaceDir).merged,
});
const extensions = await extensionManager.loadExtensions();
@@ -68,7 +67,6 @@ export async function handleUpdate(args: UpdateArgs) {
extensionManager,
updateState,
() => {},
settings.experimental?.extensionReloading,
))!;
if (
updatedExtensionInfo.originalVersion !==
@@ -1,122 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import { describe, it, expect, vi, type MockInstance } from 'vitest';
import { handleValidate, validateCommand } from './validate.js';
import yargs from 'yargs';
import { createExtension } from '../../test-utils/createExtension.js';
import path from 'node:path';
import * as os from 'node:os';
import { debugLogger } from '@google/gemini-cli-core';
describe('extensions validate command', () => {
it('should fail if no path is provided', () => {
const validationParser = yargs([]).command(validateCommand).fail(false);
expect(() => validationParser.parse('validate')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
});
describe('handleValidate', () => {
let debugLoggerLogSpy: MockInstance;
let debugLoggerWarnSpy: MockInstance;
let debugLoggerErrorSpy: MockInstance;
let processSpy: MockInstance;
let tempHomeDir: string;
let tempWorkspaceDir: string;
beforeEach(() => {
debugLoggerLogSpy = vi.spyOn(debugLogger, 'log');
debugLoggerWarnSpy = vi.spyOn(debugLogger, 'warn');
debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-home'));
tempWorkspaceDir = fs.mkdtempSync(path.join(tempHomeDir, 'test-workspace'));
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
});
it('should validate an extension from a local dir', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'local-ext-name',
version: '1.0.0',
});
await handleValidate({
path: 'local-ext-name',
});
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Extension local-ext-name has been successfully validated.',
);
});
it('should throw an error if the extension name is invalid', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'INVALID_NAME',
version: '1.0.0',
});
await handleValidate({
path: 'INVALID_NAME',
});
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Invalid extension name: "INVALID_NAME". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.',
),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should warn if version is not formatted with semver', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'valid-name',
version: '1',
});
await handleValidate({
path: 'valid-name',
});
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Version '1' does not appear to be standard semver (e.g., 1.0.0).",
),
);
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
'Extension valid-name has been successfully validated.',
);
});
it('should throw an error if context files are missing', async () => {
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'valid-name',
version: '1.0.0',
contextFileName: 'contextFile.md',
});
fs.rmSync(path.join(tempWorkspaceDir, 'valid-name/contextFile.md'));
await handleValidate({
path: 'valid-name',
});
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'The following context files referenced in gemini-extension.json are missing: contextFile.md',
),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
});
@@ -1,105 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import semver from 'semver';
import { getErrorMessage } from '../../utils/errors.js';
import type { ExtensionConfig } from '../../config/extension.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { loadSettings } from '../../config/settings.js';
interface ValidateArgs {
path: string;
}
export async function handleValidate(args: ValidateArgs) {
try {
await validateExtension(args);
debugLogger.log(`Extension ${args.path} has been successfully validated.`);
} catch (error) {
debugLogger.error(getErrorMessage(error));
process.exit(1);
}
}
async function validateExtension(args: ValidateArgs) {
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
settings: loadSettings(workspaceDir).merged,
});
const absoluteInputPath = path.resolve(args.path);
const extensionConfig: ExtensionConfig =
extensionManager.loadExtensionConfig(absoluteInputPath);
const warnings: string[] = [];
const errors: string[] = [];
if (extensionConfig.contextFileName) {
const contextFileNames = Array.isArray(extensionConfig.contextFileName)
? extensionConfig.contextFileName
: [extensionConfig.contextFileName];
const missingContextFiles: string[] = [];
for (const contextFilePath of contextFileNames) {
const contextFileAbsolutePath = path.resolve(
absoluteInputPath,
contextFilePath,
);
if (!fs.existsSync(contextFileAbsolutePath)) {
missingContextFiles.push(contextFilePath);
}
}
if (missingContextFiles.length > 0) {
errors.push(
`The following context files referenced in gemini-extension.json are missing: ${missingContextFiles}`,
);
}
}
if (!semver.valid(extensionConfig.version)) {
warnings.push(
`Warning: Version '${extensionConfig.version}' does not appear to be standard semver (e.g., 1.0.0).`,
);
}
if (warnings.length > 0) {
debugLogger.warn('Validation warnings:');
for (const warning of warnings) {
debugLogger.warn(` - ${warning}`);
}
}
if (errors.length > 0) {
debugLogger.error('Validation failed with the following errors:');
for (const error of errors) {
debugLogger.error(` - ${error}`);
}
throw new Error('Extension validation failed.');
}
}
export const validateCommand: CommandModule = {
command: 'validate <path>',
describe: 'Validates an extension from a local path.',
builder: (yargs) =>
yargs.positional('path', {
describe: 'The path of the extension to validate.',
type: 'string',
demandOption: true,
}),
handler: async (args) => {
await handleValidate({
path: args['path'] as string,
});
},
};
+21 -27
View File
@@ -21,33 +21,27 @@ vi.mock('../../config/extensions/storage.js', () => ({
},
}));
vi.mock('../../config/extension-manager.js');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
createTransport: vi.fn(),
MCPServerStatus: {
CONNECTED: 'CONNECTED',
CONNECTING: 'CONNECTING',
DISCONNECTED: 'DISCONNECTED',
},
Storage: vi.fn().mockImplementation((_cwd: string) => ({
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json',
getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash',
})),
GEMINI_DIR: '.gemini',
getErrorMessage: (e: unknown) =>
e instanceof Error ? e.message : String(e),
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
vi.mock('@google/gemini-cli-core', () => ({
createTransport: vi.fn(),
MCPServerStatus: {
CONNECTED: 'CONNECTED',
CONNECTING: 'CONNECTING',
DISCONNECTED: 'DISCONNECTED',
},
Storage: vi.fn().mockImplementation((_cwd: string) => ({
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json',
getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash',
})),
GEMINI_DIR: '.gemini',
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedGetUserExtensionsDir =
+5 -1
View File
@@ -44,7 +44,11 @@ describe('validateAuthMethod', () => {
it('should return an error message if GEMINI_API_KEY is not set', () => {
vi.stubEnv('GEMINI_API_KEY', undefined);
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBe(
'GEMINI_API_KEY not found. Find your existing key or generate a new one at: https://aistudio.google.com/apikey\n' +
'\n' +
'To continue, please set the GEMINI_API_KEY environment variable or add it to a .env file.',
);
});
});
+7
View File
@@ -17,6 +17,13 @@ export function validateAuthMethod(authMethod: string): string | null {
}
if (authMethod === AuthType.USE_GEMINI) {
if (!process.env['GEMINI_API_KEY']) {
return (
'GEMINI_API_KEY not found. Find your existing key or generate a new one at: https://aistudio.google.com/apikey\n' +
'\n' +
'To continue, please set the GEMINI_API_KEY environment variable or add it to a .env file.'
);
}
return null;
}
+53 -60
View File
@@ -36,7 +36,7 @@ vi.mock('./sandboxConfig.js', () => ({
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof import('fs')>();
const pathMod = await import('node:path');
const mockHome = pathMod.resolve(pathMod.sep, 'mock', 'home', 'user');
const mockHome = '/mock/home/user';
const MOCK_CWD1 = process.cwd();
const MOCK_CWD2 = pathMod.resolve(pathMod.sep, 'home', 'user', 'project');
@@ -69,7 +69,7 @@ vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
return {
...actualOs,
homedir: vi.fn(() => path.resolve(path.sep, 'mock', 'home', 'user')),
homedir: vi.fn(() => '/mock/home/user'),
};
});
@@ -451,36 +451,6 @@ describe('parseArguments', () => {
mockConsoleError.mockRestore();
});
it('should throw an error when resuming a session without prompt in non-interactive mode', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '--resume', 'session-id'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
try {
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
),
);
} finally {
mockExit.mockRestore();
mockConsoleError.mockRestore();
process.stdin.isTTY = originalIsTTY;
}
});
it('should support comma-separated values for --allowed-tools', async () => {
process.argv = [
'node',
@@ -782,11 +752,11 @@ describe('mergeMcpServers', () => {
});
describe('mergeExcludeTools', () => {
const defaultExcludes = new Set([
const defaultExcludes = [
SHELL_TOOL_NAME,
EDIT_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
]);
];
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
@@ -829,7 +799,7 @@ describe('mergeExcludeTools', () => {
argv,
);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
);
expect(config.getExcludeTools()).toHaveLength(5);
});
@@ -851,7 +821,7 @@ describe('mergeExcludeTools', () => {
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3']),
expect.arrayContaining(['tool1', 'tool2', 'tool3']),
);
expect(config.getExcludeTools()).toHaveLength(3);
});
@@ -882,7 +852,7 @@ describe('mergeExcludeTools', () => {
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3', 'tool4']),
expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4']),
);
expect(config.getExcludeTools()).toHaveLength(4);
});
@@ -893,7 +863,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(new Set([]));
expect(config.getExcludeTools()).toEqual([]);
});
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
@@ -911,7 +881,9 @@ describe('mergeExcludeTools', () => {
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2']));
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2']),
);
expect(config.getExcludeTools()).toHaveLength(2);
});
@@ -931,7 +903,9 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2']));
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2']),
);
expect(config.getExcludeTools()).toHaveLength(2);
});
@@ -1172,7 +1146,9 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(baseSettings, 'test-session', argv);
expect(config.getAllowedMcpServers()).toEqual(['server1']);
expect(config.getMcpServers()).toEqual({
server1: { url: 'http://localhost:8080' },
});
});
it('should allow multiple specified MCP servers', async () => {
@@ -1186,7 +1162,10 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(baseSettings, 'test-session', argv);
expect(config.getAllowedMcpServers()).toEqual(['server1', 'server3']);
expect(config.getMcpServers()).toEqual({
server1: { url: 'http://localhost:8080' },
server3: { url: 'http://localhost:8082' },
});
});
it('should handle server names that do not exist', async () => {
@@ -1200,14 +1179,16 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(baseSettings, 'test-session', argv);
expect(config.getAllowedMcpServers()).toEqual(['server1', 'server4']);
expect(config.getMcpServers()).toEqual({
server1: { url: 'http://localhost:8080' },
});
});
it('should allow no MCP servers if the flag is provided but empty', async () => {
process.argv = ['node', 'script.js', '--allowed-mcp-server-names', ''];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(baseSettings, 'test-session', argv);
expect(config.getAllowedMcpServers()).toEqual(['']);
expect(config.getMcpServers()).toEqual({});
});
it('should read allowMCPServers from settings', async () => {
@@ -1218,7 +1199,10 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
mcp: { allowed: ['server1', 'server2'] },
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']);
expect(config.getMcpServers()).toEqual({
server1: { url: 'http://localhost:8080' },
server2: { url: 'http://localhost:8081' },
});
});
it('should read excludeMCPServers from settings', async () => {
@@ -1229,7 +1213,9 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
mcp: { excluded: ['server1', 'server2'] },
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getBlockedMcpServers()).toEqual(['server1', 'server2']);
expect(config.getMcpServers()).toEqual({
server3: { url: 'http://localhost:8082' },
});
});
it('should override allowMCPServers with excludeMCPServers if overlapping', async () => {
@@ -1243,8 +1229,9 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']);
expect(config.getBlockedMcpServers()).toEqual(['server1']);
expect(config.getMcpServers()).toEqual({
server2: { url: 'http://localhost:8081' },
});
});
it('should prioritize mcp server flag if set', async () => {
@@ -1263,7 +1250,9 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getAllowedMcpServers()).toEqual(['server1']);
expect(config.getMcpServers()).toEqual({
server1: { url: 'http://localhost:8080' },
});
});
it('should prioritize CLI flag over both allowed and excluded settings', async () => {
@@ -1284,8 +1273,10 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getAllowedMcpServers()).toEqual(['server2', 'server3']);
expect(config.getBlockedMcpServers()).toEqual([]);
expect(config.getMcpServers()).toEqual({
server2: { url: 'http://localhost:8081' },
server3: { url: 'http://localhost:8082' },
});
});
});
@@ -1506,9 +1497,7 @@ describe('loadCliConfig folderTrust', () => {
describe('loadCliConfig with includeDirectories', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue(
path.resolve(path.sep, 'mock', 'home', 'user'),
);
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve(path.sep, 'home', 'user', 'project'),
@@ -1556,7 +1545,7 @@ describe('loadCliConfig with includeDirectories', () => {
});
});
describe('loadCliConfig compressionThreshold', () => {
describe('loadCliConfig chatCompression', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
@@ -1569,24 +1558,28 @@ describe('loadCliConfig compressionThreshold', () => {
vi.restoreAllMocks();
});
it('should pass settings to the core config', async () => {
it('should pass chatCompression settings to the core config', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
model: {
compressionThreshold: 0.5,
chatCompression: {
contextPercentageThreshold: 0.5,
},
},
};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(await config.getCompressionThreshold()).toBe(0.5);
expect(config.getChatCompression()).toEqual({
contextPercentageThreshold: 0.5,
});
});
it('should have undefined compressionThreshold if not in settings', async () => {
it('should have undefined chatCompression if not in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(await config.getCompressionThreshold()).toBeUndefined();
expect(config.getChatCompression()).toBeUndefined();
});
});
+169 -57
View File
@@ -4,14 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { homedir } from 'node:os';
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 type {
FileFilteringOptions,
MCPServerConfig,
OutputFormat,
GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { extensionsCommand } from '../commands/extensions.js';
import {
Config,
loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename,
ApprovalMode,
@@ -29,7 +38,6 @@ import {
getPty,
EDIT_TOOL_NAME,
debugLogger,
loadServerHierarchicalMemory,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
@@ -41,10 +49,9 @@ import { appEvents } from '../utils/events.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js';
import { ExtensionManager } from './extension-manager.js';
import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
import type { ExtensionLoader } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
export interface CliArgs {
query: string | undefined;
@@ -61,9 +68,6 @@ export interface CliArgs {
experimentalAcp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | 'latest' | undefined;
listSessions: boolean | undefined;
deleteSession: string | undefined;
includeDirectories: string[] | undefined;
screenReader: boolean | undefined;
useSmartEdit: boolean | undefined;
@@ -175,35 +179,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('resume', {
alias: 'r',
type: 'string',
// `skipValidation` so that we can distinguish between it being passed with a value, without
// one, and not being passed at all.
skipValidation: true,
description:
'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)',
coerce: (value: string): string => {
// When --resume passed with a value (`gemini --resume 123`): value = "123" (string)
// When --resume passed without a value (`gemini --resume`): value = "" (string)
// When --resume not passed at all: this `coerce` function is not called at all, and
// `yargsInstance.argv.resume` is undefined.
if (value === '') {
return 'latest';
}
return value;
},
})
.option('list-sessions', {
type: 'boolean',
description:
'List available sessions for the current project and exit.',
})
.option('delete-session', {
type: 'string',
description:
'Delete a session by index number (use --list-sessions to see available sessions).',
})
.option('include-directories', {
type: 'array',
string: true,
@@ -259,11 +234,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv.resume && !argv.prompt && !process.stdin.isTTY) {
throw new Error(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
);
}
if (argv.yolo && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
@@ -325,6 +295,49 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
return result as unknown as CliArgs;
}
// This function is now a thin wrapper around the server's implementation.
// It's kept in the CLI for now as App.tsx directly calls it for memory refresh.
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
export async function loadHierarchicalGeminiMemory(
currentWorkingDirectory: string,
includeDirectoriesToReadGemini: readonly string[] = [],
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
extensionLoader: ExtensionLoader,
folderTrust: boolean,
memoryImportFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
): Promise<{ memoryContent: string; fileCount: number; filePaths: string[] }> {
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
const realHome = fs.realpathSync(path.resolve(homedir()));
const isHomeDirectory = realCwd === realHome;
// If it is the home directory, pass an empty string to the core memory
// function to signal that it should skip the workspace search.
const effectiveCwd = isHomeDirectory ? '' : currentWorkingDirectory;
if (debugMode) {
debugLogger.debug(
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory} (memoryImportFormat: ${memoryImportFormat})`,
);
}
// Directly call the server function with the corrected path.
return loadServerHierarchicalMemory(
effectiveCwd,
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensionLoader,
folderTrust,
memoryImportFormat,
fileFilteringOptions,
settings.context?.discoveryMaxDirs,
);
}
/**
* Creates a filter function to determine if a tool should be excluded.
*
@@ -416,26 +429,26 @@ export async function loadCliConfig(
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
});
await extensionManager.loadExtensions();
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount, filePaths } =
await loadServerHierarchicalMemory(
await loadHierarchicalGeminiMemory(
cwd,
settings.context?.loadMemoryFromIncludeDirectories
? includeDirectories
: [],
debugMode,
fileService,
settings,
extensionManager,
trustedFolder,
memoryImportFormat,
memoryFileFiltering,
settings.context?.discoveryMaxDirs,
);
let mcpServers = mergeMcpServers(settings, extensionManager.getExtensions());
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
@@ -506,8 +519,26 @@ export async function loadCliConfig(
approvalMode,
);
// Debug: Log the merged policy configuration
// Only log when message bus integration is enabled (when policies are active)
const enableMessageBusIntegration =
settings.tools?.enableMessageBusIntegration ?? false;
if (enableMessageBusIntegration) {
debugLogger.debug('=== Policy Engine Configuration ===');
debugLogger.debug(
`Default decision: ${policyEngineConfig.defaultDecision}`,
);
debugLogger.debug(`Total rules: ${policyEngineConfig.rules?.length || 0}`);
if (policyEngineConfig.rules && policyEngineConfig.rules.length > 0) {
debugLogger.debug('Rules (sorted by priority):');
policyEngineConfig.rules.forEach((rule, index) => {
debugLogger.debug(
` [${index}] toolName: ${rule.toolName || '*'}, decision: ${rule.decision}, priority: ${rule.priority}, argsPattern: ${rule.argsPattern ? rule.argsPattern.source : 'none'}`,
);
});
}
debugLogger.debug('===================================');
}
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
@@ -552,8 +583,37 @@ export async function loadCliConfig(
const excludeTools = mergeExcludeTools(
settings,
extensionManager.getExtensions(),
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
if (!argv.allowedMcpServerNames) {
if (settings.mcp?.allowed) {
mcpServers = allowedMcpServers(
mcpServers,
settings.mcp.allowed,
blockedMcpServers,
);
}
if (settings.mcp?.excluded) {
const excludedNames = new Set(settings.mcp.excluded.filter(Boolean));
if (excludedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key]) => !excludedNames.has(key)),
);
}
}
}
if (argv.allowedMcpServerNames) {
mcpServers = allowedMcpServers(
mcpServers,
argv.allowedMcpServerNames,
blockedMcpServers,
);
}
const useModelRouter = settings.experimental?.useModelRouter ?? true;
const defaultModel = useModelRouter
@@ -591,11 +651,7 @@ export async function loadCliConfig(
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,
mcpServers,
userMemory: memoryContent,
geminiMdFileCount: fileCount,
geminiMdFilePaths: filePaths,
@@ -622,15 +678,13 @@ export async function loadCliConfig(
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
blockedMcpServers,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
compressionThreshold: settings.model?.compressionThreshold,
chatCompression: settings.model?.chatCompression,
folderTrust,
interactive,
trustedFolder,
@@ -656,20 +710,78 @@ export async function loadCliConfig(
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors ?? false,
ptyInfo: ptyInfo?.name,
modelConfigServiceConfig: settings.modelConfigs,
// TODO: loading of hooks based on workspace trust
enableHooks: settings.tools?.enableHooks ?? false,
hooks: settings.hooks || {},
});
}
function allowedMcpServers(
mcpServers: { [x: string]: MCPServerConfig },
allowMCPServers: string[],
blockedMcpServers: Array<{ name: string; extensionName: string }>,
) {
const allowedNames = new Set(allowMCPServers.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key, server]) => {
const isAllowed = allowedNames.has(key);
if (!isAllowed) {
blockedMcpServers.push({
name: key,
extensionName: server.extension?.name || '',
});
}
return isAllowed;
}),
);
} else {
blockedMcpServers.push(
...Object.entries(mcpServers).map(([key, server]) => ({
name: key,
extensionName: server.extension?.name || '',
})),
);
mcpServers = {};
}
return mcpServers;
}
function mergeMcpServers(settings: Settings, extensions: GeminiCLIExtension[]) {
const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) {
if (!extension.isActive) {
continue;
}
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
if (mcpServers[key]) {
debugLogger.warn(
`Skipping extension MCP config for server with key "${key}" as it already exists.`,
);
return;
}
mcpServers[key] = {
...server,
extension,
};
});
}
return mcpServers;
}
function mergeExcludeTools(
settings: Settings,
extensions: GeminiCLIExtension[],
extraExcludes?: string[] | undefined,
): string[] {
const allExcludeTools = new Set([
...(settings.tools?.exclude || []),
...(extraExcludes || []),
]);
for (const extension of extensions) {
if (!extension.isActive) {
continue;
}
for (const tool of extension.excludeTools || []) {
allExcludeTools.add(tool);
}
}
return [...allExcludeTools];
}
+45 -99
View File
@@ -12,11 +12,7 @@ import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'
import { type Settings, SettingScope } from './settings.js';
import { createHash, randomUUID } from 'node:crypto';
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
import {
isWorkspaceTrusted,
loadTrustedFolders,
TrustLevel,
} from './trustedFolders.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import {
cloneFromGit,
downloadFromGitHubRelease,
@@ -28,7 +24,6 @@ import {
ExtensionDisableEvent,
ExtensionEnableEvent,
ExtensionInstallEvent,
ExtensionLoader,
ExtensionUninstallEvent,
ExtensionUpdateEvent,
getErrorMessage,
@@ -37,7 +32,6 @@ import {
logExtensionInstallEvent,
logExtensionUninstall,
logExtensionUpdateEvent,
type ExtensionEvents,
type MCPServerConfig,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
@@ -56,7 +50,11 @@ import {
maybePromptForSettings,
type ExtensionSetting,
} from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import type {
ExtensionEvents,
ExtensionLoader,
} from '@google/gemini-cli-core/src/utils/extensionLoader.js';
import { EventEmitter } from 'node:events';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
@@ -64,7 +62,6 @@ interface ExtensionManagerParams {
requestConsent: (consent: string) => Promise<boolean>;
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
workspaceDir: string;
eventEmitter?: EventEmitter<ExtensionEvents>;
}
/**
@@ -72,7 +69,7 @@ interface ExtensionManagerParams {
*
* You must call `loadExtensions` prior to calling other methods on this class.
*/
export class ExtensionManager extends ExtensionLoader {
export class ExtensionManager implements ExtensionLoader {
private extensionEnablementManager: ExtensionEnablementManager;
private settings: Settings;
private requestConsent: (consent: string) => Promise<boolean>;
@@ -82,9 +79,9 @@ export class ExtensionManager extends ExtensionLoader {
private telemetryConfig: Config;
private workspaceDir: string;
private loadedExtensions: GeminiCLIExtension[] | undefined;
private eventEmitter: EventEmitter<ExtensionEvents>;
constructor(options: ExtensionManagerParams) {
super(options.eventEmitter);
this.workspaceDir = options.workspaceDir;
this.extensionEnablementManager = new ExtensionEnablementManager(
options.enabledExtensionOverrides,
@@ -101,6 +98,7 @@ export class ExtensionManager extends ExtensionLoader {
});
this.requestConsent = options.requestConsent;
this.requestSetting = options.requestSetting ?? undefined;
this.eventEmitter = new EventEmitter();
}
setRequestConsent(
@@ -124,38 +122,25 @@ export class ExtensionManager extends ExtensionLoader {
return this.loadedExtensions!;
}
extensionEvents(): EventEmitter<ExtensionEvents> {
return this.eventEmitter;
}
async installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
previousExtensionConfig?: ExtensionConfig,
): Promise<GeminiCLIExtension> {
if (
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release') &&
this.settings.security?.blockGitExtensions
) {
throw new Error(
'Installing extensions from remote sources is disallowed by your current settings.',
);
}
const isUpdate = !!previousExtensionConfig;
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
let extension: GeminiCLIExtension | null;
try {
if (!isWorkspaceTrusted(this.settings).isTrusted) {
if (
await this.requestConsent(
`The current workspace at "${this.workspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`,
)
) {
const trustedFolders = loadTrustedFolders();
trustedFolders.setValue(this.workspaceDir, TrustLevel.TRUST_FOLDER);
} else {
throw new Error(
`Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`,
);
}
throw new Error(
`Could not install extension from untrusted folder at ${installMetadata.source}`,
);
}
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
@@ -306,13 +291,13 @@ export class ExtensionManager extends ExtensionLoader {
await fs.promises.writeFile(metadataPath, metadataString);
// TODO: Gracefully handle this call failing, we should back up the old
// extension prior to overwriting it and then restore and restart it.
// extension prior to overwriting it and then restore it.
extension = await this.loadExtension(destinationPath)!;
if (!extension) {
throw new Error(`Extension not found`);
}
if (isUpdate) {
await logExtensionUpdateEvent(
logExtensionUpdateEvent(
this.telemetryConfig,
new ExtensionUpdateEvent(
hashValue(newExtensionConfig.name),
@@ -323,8 +308,9 @@ export class ExtensionManager extends ExtensionLoader {
'success',
),
);
this.eventEmitter.emit('extensionUpdated', { extension });
} else {
await logExtensionInstallEvent(
logExtensionInstallEvent(
this.telemetryConfig,
new ExtensionInstallEvent(
hashValue(newExtensionConfig.name),
@@ -334,6 +320,7 @@ export class ExtensionManager extends ExtensionLoader {
'success',
),
);
this.eventEmitter.emit('extensionInstalled', { extension });
this.enableExtension(newExtensionConfig.name, SettingScope.User);
}
} finally {
@@ -357,7 +344,7 @@ export class ExtensionManager extends ExtensionLoader {
? getExtensionId(config, installMetadata)
: undefined;
if (isUpdate) {
await logExtensionUpdateEvent(
logExtensionUpdateEvent(
this.telemetryConfig,
new ExtensionUpdateEvent(
hashValue(config?.name ?? ''),
@@ -369,7 +356,7 @@ export class ExtensionManager extends ExtensionLoader {
),
);
} else {
await logExtensionInstallEvent(
logExtensionInstallEvent(
this.telemetryConfig,
new ExtensionInstallEvent(
hashValue(newExtensionConfig?.name ?? ''),
@@ -398,12 +385,8 @@ export class ExtensionManager extends ExtensionLoader {
if (!extension) {
throw new Error(`Extension not found.`);
}
await this.unloadExtension(extension);
const storage = new ExtensionStorage(
extension.installMetadata?.type === 'link'
? extension.name
: path.basename(extension.path),
);
this.unloadExtension(extension);
const storage = new ExtensionStorage(extension.name);
await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
@@ -416,7 +399,7 @@ export class ExtensionManager extends ExtensionLoader {
this.extensionEnablementManager.remove(extension.name);
await logExtensionUninstall(
logExtensionUninstall(
this.telemetryConfig,
new ExtensionUninstallEvent(
hashValue(extension.name),
@@ -424,11 +407,9 @@ export class ExtensionManager extends ExtensionLoader {
'success',
),
);
this.eventEmitter.emit('extensionUninstalled', { extension });
}
/**
* Loads all installed extensions, should only be called once.
*/
async loadExtensions(): Promise<GeminiCLIExtension[]> {
if (this.loadedExtensions) {
throw new Error('Extensions already loaded, only load extensions once.');
@@ -440,14 +421,12 @@ export class ExtensionManager extends ExtensionLoader {
}
for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir);
await this.loadExtension(extensionDir);
}
return this.loadedExtensions;
}
/**
* Adds `extension` to the list of extensions and starts it if appropriate.
*/
private async loadExtension(
extensionDir: string,
): Promise<GeminiCLIExtension | null> {
@@ -458,13 +437,6 @@ export class ExtensionManager extends ExtensionLoader {
const installMetadata = loadInstallMetadata(extensionDir);
let effectiveExtensionPath = extensionDir;
if (
(installMetadata?.type === 'git' ||
installMetadata?.type === 'github-release') &&
this.settings.security?.blockGitExtensions
) {
return null;
}
if (installMetadata?.type === 'link') {
effectiveExtensionPath = installMetadata.source;
@@ -515,9 +487,8 @@ export class ExtensionManager extends ExtensionLoader {
),
id: getExtensionId(config, installMetadata),
};
this.loadedExtensions = [...this.loadedExtensions, extension];
await this.maybeStartExtension(extension);
this.eventEmitter.emit('extensionLoaded', { extension });
this.getExtensions().push(extension);
return extension;
} catch (e) {
debugLogger.error(
@@ -529,17 +500,11 @@ export class ExtensionManager extends ExtensionLoader {
}
}
/**
* Removes `extension` from the list of extensions and stops it if
* appropriate.
*/
private unloadExtension(
extension: GeminiCLIExtension,
): Promise<void> | undefined {
private unloadExtension(extension: GeminiCLIExtension) {
this.loadedExtensions = this.getExtensions().filter(
(entry) => extension !== entry,
);
return this.maybeStopExtension(extension);
this.eventEmitter.emit('extensionUnloaded', { extension });
}
loadExtensionConfig(extensionDir: string): ExtensionConfig {
@@ -589,8 +554,6 @@ export class ExtensionManager extends ExtensionLoader {
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${status} ${extension.name} (${extension.version})`;
output += `\n ID: ${extension.id}`;
output += `\n name: ${hashValue(extension.name)}`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
@@ -638,27 +601,17 @@ export class ExtensionManager extends ExtensionLoader {
throw new Error(`Extension with name ${name} does not exist.`);
}
if (scope !== SettingScope.Session) {
const scopePath =
scope === SettingScope.Workspace ? this.workspaceDir : os.homedir();
this.extensionEnablementManager.disable(name, true, scopePath);
}
await logExtensionDisable(
const scopePath =
scope === SettingScope.Workspace ? this.workspaceDir : os.homedir();
this.extensionEnablementManager.disable(name, true, scopePath);
logExtensionDisable(
this.telemetryConfig,
new ExtensionDisableEvent(hashValue(name), extension.id, scope),
);
if (!this.config || this.config.getEnableExtensionReloading()) {
// Only toggle the isActive state if we are actually going to disable it
// in the current session, or we haven't been initialized yet.
extension.isActive = false;
}
await this.maybeStopExtension(extension);
extension.isActive = false;
this.eventEmitter.emit('extensionDisabled', { extension });
}
/**
* Enables an existing extension for a given scope, and starts it if
* appropriate.
*/
async enableExtension(name: string, scope: SettingScope) {
if (
scope === SettingScope.System ||
@@ -672,22 +625,15 @@ export class ExtensionManager extends ExtensionLoader {
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
if (scope !== SettingScope.Session) {
const scopePath =
scope === SettingScope.Workspace ? this.workspaceDir : os.homedir();
this.extensionEnablementManager.enable(name, true, scopePath);
}
await logExtensionEnable(
const scopePath =
scope === SettingScope.Workspace ? this.workspaceDir : os.homedir();
this.extensionEnablementManager.enable(name, true, scopePath);
logExtensionEnable(
this.telemetryConfig,
new ExtensionEnableEvent(hashValue(name), extension.id, scope),
);
if (!this.config || this.config.getEnableExtensionReloading()) {
// Only toggle the isActive state if we are actually going to disable it
// in the current session, or we haven't been initialized yet.
extension.isActive = true;
}
await this.maybeStartExtension(extension);
extension.isActive = true;
this.eventEmitter.emit('extensionEnabled', { extension });
}
}
+10 -187
View File
@@ -16,10 +16,7 @@ import {
KeychainTokenStorage,
} from '@google/gemini-cli-core';
import { loadSettings, SettingScope } from './settings.js';
import {
isWorkspaceTrusted,
resetTrustedFoldersForTesting,
} from './trustedFolders.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { join } from 'node:path';
@@ -185,7 +182,6 @@ describe('extension tests', () => {
requestSetting: mockPromptForSettings,
settings: loadSettings(tempWorkspaceDir).merged,
});
resetTrustedFoldersForTesting();
});
afterEach(() => {
@@ -594,34 +590,6 @@ describe('extension tests', () => {
consoleSpy.mockRestore();
});
it('should not load github extensions if blockGitExtensions is set', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://somehost.com/foo/bar',
},
});
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: blockGitExtensionsSetting,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension).toBeUndefined();
});
describe('id generation', () => {
it('should generate id from source for non-github git urls', async () => {
createExtension({
@@ -904,111 +872,6 @@ describe('extension tests', () => {
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
it('should not install a github extension if blockGitExtensions is set', async () => {
const gitUrl = 'https://somehost.com/somerepo.git';
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: blockGitExtensionsSetting,
});
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
}),
).rejects.toThrow(
'Installing extensions from remote sources is disallowed by your current settings.',
);
});
it('should prompt for trust if workspace is not trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
});
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(mockRequestConsent).toHaveBeenCalledWith(
`The current workspace at "${tempWorkspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`,
);
});
it('should not install if user denies trust', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
});
mockRequestConsent.mockImplementation(async (message) => {
if (
message.includes(
'is not trusted. Do you want to trust this workspace to install extensions?',
)
) {
return false;
}
return true;
});
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow(
`Could not install extension because the current workspace at ${tempWorkspaceDir} is not trusted.`,
);
});
it('should add the workspace to trusted folders if user consents', async () => {
const trustedFoldersPath = path.join(
tempHomeDir,
'.gemini',
'trustedFolders.json',
);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
});
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
});
expect(fs.existsSync(trustedFoldersPath)).toBe(true);
const trustedFolders = JSON.parse(
fs.readFileSync(trustedFoldersPath, 'utf-8'),
);
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
});
describe.each([true, false])(
'with previous extension config: %s',
(isUpdate: boolean) => {
@@ -1607,34 +1470,6 @@ This extension will run the following MCP servers:
expect(fs.existsSync(otherExtDir)).toBe(true);
});
it('should uninstall an extension on non-matching extension directory name', async () => {
// Create an extension with a name that differs from the directory name.
const sourceExtDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'My-Local-Extension',
version: '1.0.0',
});
const newSourceExtDir = path.join(
userExtensionsDir,
'my-local-extension',
);
fs.renameSync(sourceExtDir, newSourceExtDir);
const otherExtDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'other-extension',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.uninstallExtension('my-local-extension', false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(fs.existsSync(newSourceExtDir)).toBe(false);
expect(extensionManager.getExtensions()).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true);
});
it('should throw an error if the extension does not exist', async () => {
await extensionManager.loadExtensions();
await expect(
@@ -1746,10 +1581,7 @@ This extension will run the following MCP servers:
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension(
'my-extension',
SettingScope.User,
);
extensionManager.disableExtension('my-extension', SettingScope.User);
expect(
isEnabled({
name: 'my-extension',
@@ -1766,10 +1598,7 @@ This extension will run the following MCP servers:
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension(
'my-extension',
SettingScope.Workspace,
);
extensionManager.disableExtension('my-extension', SettingScope.Workspace);
expect(
isEnabled({
name: 'my-extension',
@@ -1792,14 +1621,8 @@ This extension will run the following MCP servers:
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension(
'my-extension',
SettingScope.User,
);
await extensionManager.disableExtension(
'my-extension',
SettingScope.User,
);
extensionManager.disableExtension('my-extension', SettingScope.User);
extensionManager.disableExtension('my-extension', SettingScope.User);
expect(
isEnabled({
name: 'my-extension',
@@ -1830,7 +1653,7 @@ This extension will run the following MCP servers:
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension('ext1', SettingScope.Workspace);
extensionManager.disableExtension('ext1', SettingScope.Workspace);
expect(mockLogExtensionDisable).toHaveBeenCalled();
expect(ExtensionDisableEvent).toHaveBeenCalledWith(
@@ -1858,7 +1681,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension('ext1', SettingScope.User);
extensionManager.disableExtension('ext1', SettingScope.User);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
@@ -1875,7 +1698,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension('ext1', SettingScope.Workspace);
extensionManager.disableExtension('ext1', SettingScope.Workspace);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
@@ -1896,8 +1719,8 @@ This extension will run the following MCP servers:
},
});
await extensionManager.loadExtensions();
await extensionManager.disableExtension('ext1', SettingScope.Workspace);
await extensionManager.enableExtension('ext1', SettingScope.Workspace);
extensionManager.disableExtension('ext1', SettingScope.Workspace);
extensionManager.enableExtension('ext1', SettingScope.Workspace);
expect(mockLogExtensionEnable).toHaveBeenCalled();
expect(ExtensionEnableEvent).toHaveBeenCalledWith(
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import {
@@ -296,52 +296,60 @@ describe('extensionSettings', () => {
});
describe('promptForSetting', () => {
it.each([
{
description:
'should use prompts with type "password" for sensitive settings',
setting: {
name: 'API Key',
description: 'Your secret key',
envVar: 'API_KEY',
sensitive: true,
},
expectedType: 'password',
promptValue: 'secret-key',
},
{
description:
'should use prompts with type "text" for non-sensitive settings',
setting: {
name: 'Username',
description: 'Your public username',
envVar: 'USERNAME',
sensitive: false,
},
expectedType: 'text',
promptValue: 'test-user',
},
{
description: 'should default to "text" if sensitive is undefined',
setting: {
name: 'Username',
description: 'Your public username',
envVar: 'USERNAME',
},
expectedType: 'text',
promptValue: 'test-user',
},
])('$description', async ({ setting, expectedType, promptValue }) => {
vi.mocked(prompts).mockResolvedValue({ value: promptValue });
it('should use prompts with type "password" for sensitive settings', async () => {
const setting: ExtensionSetting = {
name: 'API Key',
description: 'Your secret key',
envVar: 'API_KEY',
sensitive: true,
};
vi.mocked(prompts).mockResolvedValue({ value: 'secret-key' });
const result = await promptForSetting(setting as ExtensionSetting);
const result = await promptForSetting(setting);
expect(prompts).toHaveBeenCalledWith({
type: expectedType,
type: 'password',
name: 'value',
message: `${setting.name}\n${setting.description}`,
message: 'API Key\nYour secret key',
});
expect(result).toBe(promptValue);
expect(result).toBe('secret-key');
});
it('should use prompts with type "text" for non-sensitive settings', async () => {
const setting: ExtensionSetting = {
name: 'Username',
description: 'Your public username',
envVar: 'USERNAME',
// sensitive: false,
};
vi.mocked(prompts).mockResolvedValue({ value: 'test-user' });
const result = await promptForSetting(setting);
expect(prompts).toHaveBeenCalledWith({
type: 'text',
name: 'value',
message: 'Username\nYour public username',
});
expect(result).toBe('test-user');
});
it('should default to "text" if sensitive is undefined', async () => {
const setting: ExtensionSetting = {
name: 'Username',
description: 'Your public username',
envVar: 'USERNAME',
};
vi.mocked(prompts).mockResolvedValue({ value: 'test-user' });
const result = await promptForSetting(setting);
expect(prompts).toHaveBeenCalledWith({
type: 'text',
name: 'value',
message: 'Username\nYour public username',
});
expect(result).toBe('test-user');
});
});
});
@@ -22,11 +22,6 @@ export interface ExtensionSetting {
sensitive?: boolean;
}
const getKeychainStorageName = (
extensionName: string,
extensionId: string,
): string => `Gemini CLI Extensions ${extensionName} ${extensionId}`;
export async function maybePromptForSettings(
extensionConfig: ExtensionConfig,
extensionId: string,
@@ -43,9 +38,7 @@ export async function maybePromptForSettings(
return;
}
const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath();
const keychain = new KeychainTokenStorage(
getKeychainStorageName(extensionName, extensionId),
);
const keychain = new KeychainTokenStorage(extensionId);
if (!settings || settings.length === 0) {
await clearSettings(envFilePath, keychain);
@@ -114,9 +107,7 @@ export async function getEnvContents(
return Promise.resolve({});
}
const extensionStorage = new ExtensionStorage(extensionConfig.name);
const keychain = new KeychainTokenStorage(
getKeychainStorageName(extensionConfig.name, extensionId),
);
const keychain = new KeychainTokenStorage(extensionId);
let customEnv: Record<string, string> = {};
if (fsSync.existsSync(extensionStorage.getEnvFilePath())) {
const envFile = fsSync.readFileSync(
+230 -185
View File
@@ -174,168 +174,201 @@ describe('git extension helpers', () => {
});
});
it.each([
{
testName: 'should return NOT_UPDATABLE for non-git extensions',
extension: {
installMetadata: { type: 'link', source: '' },
},
mockSetup: () => {},
expected: ExtensionUpdateState.NOT_UPDATABLE,
},
{
testName: 'should return ERROR if no remotes found',
extension: {
installMetadata: { type: 'git', source: '' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([]);
},
expected: ExtensionUpdateState.ERROR,
},
{
testName:
'should return UPDATE_AVAILABLE when remote hash is different',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
},
expected: ExtensionUpdateState.UPDATE_AVAILABLE,
},
{
testName:
'should return UP_TO_DATE when remote and local hashes are the same',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
mockGit.revparse.mockResolvedValue('same-hash');
},
expected: ExtensionUpdateState.UP_TO_DATE,
},
{
testName: 'should return ERROR on git error',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
},
expected: ExtensionUpdateState.ERROR,
},
])('$testName', async ({ extension, mockSetup, expected }) => {
const fullExtension: GeminiCLIExtension = {
it('should return NOT_UPDATABLE for non-git extensions', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'link',
source: '',
},
contextFiles: [],
...extension,
} as unknown as GeminiCLIExtension;
mockSetup();
const result = await checkForExtensionUpdate(
fullExtension,
extensionManager,
);
expect(result).toBe(expected);
};
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
});
it('should return ERROR if no remotes found', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'git',
source: '',
},
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([]);
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
it('should return UPDATE_AVAILABLE when remote hash is different', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'git',
source: 'my/ext',
},
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
});
it('should return UP_TO_DATE when remote and local hashes are the same', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'git',
source: 'my/ext',
},
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
mockGit.revparse.mockResolvedValue('same-hash');
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
});
it('should return ERROR on git error', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
installMetadata: {
type: 'git',
source: 'my/ext',
},
contextFiles: [],
};
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
const result = await checkForExtensionUpdate(extension, extensionManager);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
});
describe('fetchReleaseFromGithub', () => {
it.each([
{
ref: undefined,
allowPreRelease: true,
mockedResponse: [{ tag_name: 'v1.0.0-alpha' }, { tag_name: 'v0.9.0' }],
expectedUrl:
'https://api.github.com/repos/owner/repo/releases?per_page=1',
expectedResult: { tag_name: 'v1.0.0-alpha' },
},
{
ref: undefined,
allowPreRelease: false,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
expectedResult: { tag_name: 'v0.9.0' },
},
{
ref: 'v0.9.0',
allowPreRelease: undefined,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl:
'https://api.github.com/repos/owner/repo/releases/tags/v0.9.0',
expectedResult: { tag_name: 'v0.9.0' },
},
{
ref: undefined,
allowPreRelease: undefined,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
expectedResult: { tag_name: 'v0.9.0' },
},
])(
'should fetch release with ref=$ref and allowPreRelease=$allowPreRelease',
async ({
ref,
allowPreRelease,
mockedResponse,
expectedUrl,
expectedResult,
}) => {
fetchJsonMock.mockResolvedValueOnce(mockedResponse);
it('should fetch the latest release if allowPreRelease is true', async () => {
const releases = [{ tag_name: 'v1.0.0-alpha' }, { tag_name: 'v0.9.0' }];
fetchJsonMock.mockResolvedValueOnce(releases);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
ref,
allowPreRelease,
);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
true,
);
expect(fetchJsonMock).toHaveBeenCalledWith(expectedUrl);
expect(result).toEqual(expectedResult);
},
);
expect(fetchJsonMock).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases?per_page=1',
);
expect(result).toEqual(releases[0]);
});
it('should fetch the latest release if allowPreRelease is false', async () => {
const release = { tag_name: 'v0.9.0' };
fetchJsonMock.mockResolvedValueOnce(release);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
false,
);
expect(fetchJsonMock).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/latest',
);
expect(result).toEqual(release);
});
it('should fetch a release by tag if ref is provided', async () => {
const release = { tag_name: 'v0.9.0' };
fetchJsonMock.mockResolvedValueOnce(release);
const result = await fetchReleaseFromGithub('owner', 'repo', 'v0.9.0');
expect(fetchJsonMock).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/tags/v0.9.0',
);
expect(result).toEqual(release);
});
it('should fetch latest stable release if allowPreRelease is undefined', async () => {
const release = { tag_name: 'v0.9.0' };
fetchJsonMock.mockResolvedValueOnce(release);
const result = await fetchReleaseFromGithub('owner', 'repo');
expect(fetchJsonMock).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/latest',
);
expect(result).toEqual(release);
});
});
describe('findReleaseAsset', () => {
const assets = [
{ name: 'darwin.arm64.extension.tar.gz', url: 'url1' },
{ name: 'darwin.x64.extension.tar.gz', url: 'url2' },
{ name: 'linux.x64.extension.tar.gz', url: 'url3' },
{ name: 'win32.x64.extension.tar.gz', url: 'url4' },
{ name: 'extension-generic.tar.gz', url: 'url5' },
{ name: 'darwin.arm64.extension.tar.gz', browser_download_url: 'url1' },
{ name: 'darwin.x64.extension.tar.gz', browser_download_url: 'url2' },
{ name: 'linux.x64.extension.tar.gz', browser_download_url: 'url3' },
{ name: 'win32.x64.extension.tar.gz', browser_download_url: 'url4' },
{ name: 'extension-generic.tar.gz', browser_download_url: 'url5' },
];
it.each([
{ platform: 'darwin', arch: 'arm64', expected: assets[0] },
{ platform: 'linux', arch: 'arm64', expected: assets[2] },
it('should find asset matching platform and architecture', () => {
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(assets);
expect(result).toEqual(assets[0]);
});
{ platform: 'sunos', arch: 'x64', expected: undefined },
])(
'should find asset matching platform and architecture',
it('should find asset matching platform if arch does not match', () => {
mockPlatform.mockReturnValue('linux');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(assets);
expect(result).toEqual(assets[2]);
});
({ platform, arch, expected }) => {
mockPlatform.mockReturnValue(platform);
mockArch.mockReturnValue(arch);
const result = findReleaseAsset(assets);
expect(result).toEqual(expected);
},
);
it('should return undefined if no matching asset is found', () => {
mockPlatform.mockReturnValue('sunos');
mockArch.mockReturnValue('x64');
const result = findReleaseAsset(assets);
expect(result).toBeUndefined();
});
it('should find generic asset if it is the only one', () => {
const singleAsset = [{ name: 'extension.tar.gz', url: 'aurl5' }];
const singleAsset = [
{ name: 'extension.tar.gz', browser_download_url: 'url' },
];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(singleAsset);
@@ -344,10 +377,9 @@ describe('git extension helpers', () => {
it('should return undefined if multiple generic assets exist', () => {
const multipleGenericAssets = [
{ name: 'extension-1.tar.gz', url: 'aurl1' },
{ name: 'extension-2.tar.gz', url: 'aurl2' },
{ name: 'extension-1.tar.gz', browser_download_url: 'url1' },
{ name: 'extension-2.tar.gz', browser_download_url: 'url2' },
];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(multipleGenericAssets);
@@ -356,54 +388,67 @@ describe('git extension helpers', () => {
});
describe('parseGitHubRepoForReleases', () => {
it.each([
{
source: 'https://github.com/owner/repo.git',
owner: 'owner',
repo: 'repo',
},
{
source: 'https://github.com/owner/repo',
owner: 'owner',
repo: 'repo',
},
{
source: 'https://github.com/owner/repo/',
owner: 'owner',
repo: 'repo',
},
{
source: 'git@github.com:owner/repo.git',
owner: 'owner',
repo: 'repo',
},
{ source: 'owner/repo', owner: 'owner', repo: 'repo' },
{ source: 'owner/repo.git', owner: 'owner', repo: 'repo' },
])(
'should parse owner and repo from $source',
({ source, owner, repo }) => {
const result = tryParseGithubUrl(source)!;
expect(result.owner).toBe(owner);
expect(result.repo).toBe(repo);
},
);
it('should parse owner and repo from a full GitHub URL', () => {
const source = 'https://github.com/owner/repo.git';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should parse owner and repo from a full GitHub URL without .git', () => {
const source = 'https://github.com/owner/repo';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should parse owner and repo from a full GitHub URL with a trailing slash', () => {
const source = 'https://github.com/owner/repo/';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should parse owner and repo from a GitHub SSH URL', () => {
const source = 'git@github.com:owner/repo.git';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should return null on a non-GitHub URL', () => {
const source = 'https://example.com/owner/repo.git';
expect(tryParseGithubUrl(source)).toBe(null);
});
it.each([
{ source: 'invalid-format' },
{ source: 'https://github.com/owner/repo/extra' },
])(
'should throw error for invalid source format: $source',
({ source }) => {
expect(() => tryParseGithubUrl(source)).toThrow(
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
);
},
);
it('should parse owner and repo from a shorthand string', () => {
const source = 'owner/repo';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should handle .git suffix in repo name', () => {
const source = 'owner/repo.git';
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should throw error for invalid source format', () => {
const source = 'invalid-format';
expect(() => tryParseGithubUrl(source)).toThrow(
'Invalid GitHub repository source: invalid-format. Expected "owner/repo" or a github repo uri.',
);
});
it('should throw error for source with too many parts', () => {
const source = 'https://github.com/owner/repo/extra';
expect(() => tryParseGithubUrl(source)).toThrow(
'Invalid GitHub repository source: https://github.com/owner/repo/extra. Expected "owner/repo" or a github repo uri.',
);
});
});
describe('extractFile', () => {
+7 -15
View File
@@ -306,11 +306,8 @@ export async function downloadFromGitHubRelease(
let archiveUrl: string | undefined;
let isTar = false;
let isZip = false;
let fileName: string | undefined;
if (asset) {
archiveUrl = asset.url;
fileName = asset.name;
archiveUrl = asset.browser_download_url;
} else {
if (releaseData.tarball_url) {
archiveUrl = releaseData.tarball_url;
@@ -329,10 +326,10 @@ export async function downloadFromGitHubRelease(
errorMessage: `No assets found for release with tag ${releaseData.tag_name}`,
};
}
if (!fileName) {
fileName = path.basename(new URL(archiveUrl).pathname);
}
let downloadedAssetPath = path.join(destination, fileName);
let downloadedAssetPath = path.join(
destination,
path.basename(new URL(archiveUrl).pathname),
);
if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) {
downloadedAssetPath += '.tar.gz';
} else if (isZip && !downloadedAssetPath.endsWith('.zip')) {
@@ -417,7 +414,7 @@ interface GithubReleaseData {
interface Asset {
name: string;
url: string;
browser_download_url: string;
}
export function findReleaseAsset(assets: Asset[]): Asset | undefined {
@@ -458,13 +455,8 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined {
}
async function downloadFile(url: string, dest: string): Promise<void> {
const headers: {
'User-agent': string;
Accept: string;
Authorization?: string;
} = {
const headers: { 'User-agent': string; Authorization?: string } = {
'User-agent': 'gemini-cli',
Accept: 'application/octet-stream',
};
const token = getGitHubToken();
if (token) {
+23 -31
View File
@@ -28,7 +28,6 @@ export async function updateExtension(
extensionManager: ExtensionManager,
currentState: ExtensionUpdateState,
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void,
enableExtensionReloading?: boolean,
): Promise<ExtensionUpdateInfo | undefined> {
if (currentState === ExtensionUpdateState.UPDATING) {
return undefined;
@@ -82,9 +81,7 @@ export async function updateExtension(
type: 'SET_STATE',
payload: {
name: extension.name,
state: enableExtensionReloading
? ExtensionUpdateState.UPDATED
: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
},
});
return {
@@ -112,7 +109,6 @@ export async function updateAllUpdatableExtensions(
extensionsState: Map<string, ExtensionUpdateStatus>,
extensionManager: ExtensionManager,
dispatch: (action: ExtensionUpdateAction) => void,
enableExtensionReloading?: boolean,
): Promise<ExtensionUpdateInfo[]> {
return (
await Promise.all(
@@ -128,7 +124,6 @@ export async function updateAllUpdatableExtensions(
extensionManager,
extensionsState.get(extension.name)!.status,
dispatch,
enableExtensionReloading,
),
),
)
@@ -146,37 +141,34 @@ export async function checkForAllExtensionUpdates(
dispatch: (action: ExtensionUpdateAction) => void,
): Promise<void> {
dispatch({ type: 'BATCH_CHECK_START' });
try {
const promises: Array<Promise<void>> = [];
for (const extension of extensions) {
if (!extension.installMetadata) {
dispatch({
type: 'SET_STATE',
payload: {
name: extension.name,
state: ExtensionUpdateState.NOT_UPDATABLE,
},
});
continue;
}
const promises: Array<Promise<void>> = [];
for (const extension of extensions) {
if (!extension.installMetadata) {
dispatch({
type: 'SET_STATE',
payload: {
name: extension.name,
state: ExtensionUpdateState.CHECKING_FOR_UPDATES,
state: ExtensionUpdateState.NOT_UPDATABLE,
},
});
promises.push(
checkForExtensionUpdate(extension, extensionManager).then((state) =>
dispatch({
type: 'SET_STATE',
payload: { name: extension.name, state },
}),
),
);
continue;
}
await Promise.all(promises);
} finally {
dispatch({ type: 'BATCH_CHECK_END' });
dispatch({
type: 'SET_STATE',
payload: {
name: extension.name,
state: ExtensionUpdateState.CHECKING_FOR_UPDATES,
},
});
promises.push(
checkForExtensionUpdate(extension, extensionManager).then((state) =>
dispatch({
type: 'SET_STATE',
payload: { name: extension.name, state },
}),
),
);
}
await Promise.all(promises);
dispatch({ type: 'BATCH_CHECK_END' });
}
@@ -55,27 +55,5 @@ describe('keyBindings config', () => {
const config: KeyBindingConfig = defaultKeyBindings;
expect(config[Command.HOME]).toBeDefined();
});
it('should have correct specific bindings', () => {
// Verify navigation ignores shift
const navUp = defaultKeyBindings[Command.NAVIGATION_UP];
expect(navUp).toContainEqual({ key: 'up', shift: false });
const navDown = defaultKeyBindings[Command.NAVIGATION_DOWN];
expect(navDown).toContainEqual({ key: 'down', shift: false });
// Verify dialog navigation
const dialogNavUp = defaultKeyBindings[Command.DIALOG_NAVIGATION_UP];
expect(dialogNavUp).toContainEqual({ key: 'up', shift: false });
expect(dialogNavUp).toContainEqual({ key: 'k', shift: false });
const dialogNavDown = defaultKeyBindings[Command.DIALOG_NAVIGATION_DOWN];
expect(dialogNavDown).toContainEqual({ key: 'down', shift: false });
expect(dialogNavDown).toContainEqual({ key: 'j', shift: false });
// Verify physical home/end keys
expect(defaultKeyBindings[Command.HOME]).toContainEqual({ key: 'home' });
expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' });
});
});
});
+7 -47
View File
@@ -25,24 +25,12 @@ export enum Command {
// Screen control
CLEAR_SCREEN = 'clearScreen',
// Scrolling
SCROLL_UP = 'scrollUp',
SCROLL_DOWN = 'scrollDown',
SCROLL_HOME = 'scrollHome',
SCROLL_END = 'scrollEnd',
PAGE_UP = 'pageUp',
PAGE_DOWN = 'pageDown',
// History navigation
HISTORY_UP = 'historyUp',
HISTORY_DOWN = 'historyDown',
NAVIGATION_UP = 'navigationUp',
NAVIGATION_DOWN = 'navigationDown',
// Dialog navigation
DIALOG_NAVIGATION_UP = 'dialogNavigationUp',
DIALOG_NAVIGATION_DOWN = 'dialogNavigationDown',
// Auto-completion
ACCEPT_SUGGESTION = 'acceptSuggestion',
COMPLETION_UP = 'completionUp',
@@ -61,7 +49,6 @@ export enum Command {
SHOW_FULL_TODOS = 'showFullTodos',
TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail',
TOGGLE_MARKDOWN = 'toggleMarkdown',
TOGGLE_COPY_MODE = 'toggleCopyMode',
QUIT = 'quit',
EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines',
@@ -112,8 +99,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.ESCAPE]: [{ key: 'escape' }],
// Cursor movement
[Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
[Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
[Command.HOME]: [{ key: 'a', ctrl: true }],
[Command.END]: [{ key: 'e', ctrl: true }],
// Text deletion
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
@@ -128,43 +115,17 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Screen control
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
// Scrolling
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
[Command.SCROLL_HOME]: [{ key: 'home' }],
[Command.SCROLL_END]: [{ key: 'end' }],
[Command.PAGE_UP]: [{ key: 'pageup' }],
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
// History navigation
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
[Command.NAVIGATION_UP]: [{ key: 'up', shift: false }],
[Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }],
// Dialog navigation
// Navigation shortcuts appropriate for dialogs where we do not need to accept
// text input.
[Command.DIALOG_NAVIGATION_UP]: [
{ key: 'up', shift: false },
{ key: 'k', shift: false },
],
[Command.DIALOG_NAVIGATION_DOWN]: [
{ key: 'down', shift: false },
{ key: 'j', shift: false },
],
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }],
[Command.NAVIGATION_UP]: [{ key: 'up' }],
[Command.NAVIGATION_DOWN]: [{ key: 'down' }],
// Auto-completion
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [
{ key: 'up', shift: false },
{ key: 'p', ctrl: true, shift: false },
],
[Command.COMPLETION_DOWN]: [
{ key: 'down', shift: false },
{ key: 'n', ctrl: true, shift: false },
],
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
// Text input
// Must also exclude shift to allow shift+enter for newline
@@ -199,7 +160,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }],
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }],
[Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }],
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
[Command.QUIT]: [{ key: 'c', ctrl: true }],
[Command.EXIT]: [{ key: 'd', ctrl: true }],
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
@@ -30,22 +30,18 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Allowed tool should be allowed
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
expect(engine.check({ name: 'run_shell_command' })).toBe(
PolicyDecision.ALLOW,
);
// Excluded tool should be denied
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'write_file' })).toBe(PolicyDecision.DENY);
// Other write tools should ask user
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ASK_USER);
// Unknown tools should use default
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
expect(engine.check({ name: 'unknown_tool' })).toBe(
PolicyDecision.ASK_USER,
);
});
@@ -72,31 +68,31 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Tools from allowed server should be allowed
expect(engine.check({ name: 'allowed-server__tool1' }, undefined)).toBe(
expect(engine.check({ name: 'allowed-server__tool1' })).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'allowed-server__another_tool' })).toBe(
PolicyDecision.ALLOW,
);
expect(
engine.check({ name: 'allowed-server__another_tool' }, undefined),
).toBe(PolicyDecision.ALLOW);
// Tools from trusted server should be allowed
expect(engine.check({ name: 'trusted-server__tool1' }, undefined)).toBe(
expect(engine.check({ name: 'trusted-server__tool1' })).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'trusted-server__special_tool' })).toBe(
PolicyDecision.ALLOW,
);
expect(
engine.check({ name: 'trusted-server__special_tool' }, undefined),
).toBe(PolicyDecision.ALLOW);
// Tools from blocked server should be denied
expect(engine.check({ name: 'blocked-server__tool1' }, undefined)).toBe(
expect(engine.check({ name: 'blocked-server__tool1' })).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'blocked-server__any_tool' })).toBe(
PolicyDecision.DENY,
);
expect(
engine.check({ name: 'blocked-server__any_tool' }, undefined),
).toBe(PolicyDecision.DENY);
// Tools from unknown servers should use default
expect(engine.check({ name: 'unknown-server__tool' }, undefined)).toBe(
expect(engine.check({ name: 'unknown-server__tool' })).toBe(
PolicyDecision.ASK_USER,
);
});
@@ -118,13 +114,13 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// MCP server allowed (priority 2.1) provides general allow for server
expect(engine.check({ name: 'my-server__safe-tool' }, undefined)).toBe(
expect(engine.check({ name: 'my-server__safe-tool' })).toBe(
PolicyDecision.ALLOW,
);
// But specific tool exclude (priority 2.4) wins over server allow
expect(
engine.check({ name: 'my-server__dangerous-tool' }, undefined),
).toBe(PolicyDecision.DENY);
expect(engine.check({ name: 'my-server__dangerous-tool' })).toBe(
PolicyDecision.DENY,
);
});
it('should handle complex mixed configurations', async () => {
@@ -154,44 +150,36 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Read-only tools should be allowed (autoAccept)
expect(engine.check({ name: 'read_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'list_directory' }, undefined)).toBe(
expect(engine.check({ name: 'read_file' })).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'list_directory' })).toBe(
PolicyDecision.ALLOW,
);
// But glob is explicitly excluded, so it should be denied
expect(engine.check({ name: 'glob' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'glob' })).toBe(PolicyDecision.DENY);
// Replace should ask user (normal write tool behavior)
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ASK_USER);
// Explicitly allowed tools
expect(engine.check({ name: 'custom-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'my-server__special-tool' }, undefined)).toBe(
expect(engine.check({ name: 'custom-tool' })).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'my-server__special-tool' })).toBe(
PolicyDecision.ALLOW,
);
// MCP server tools
expect(engine.check({ name: 'allowed-server__tool' }, undefined)).toBe(
expect(engine.check({ name: 'allowed-server__tool' })).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'trusted-server__tool' }, undefined)).toBe(
expect(engine.check({ name: 'trusted-server__tool' })).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'blocked-server__tool' }, undefined)).toBe(
expect(engine.check({ name: 'blocked-server__tool' })).toBe(
PolicyDecision.DENY,
);
// Write tools should ask by default
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
expect(engine.check({ name: 'write_file' })).toBe(
PolicyDecision.ASK_USER,
);
});
@@ -210,18 +198,14 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Most tools should be allowed in YOLO mode
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
expect(engine.check({ name: 'run_shell_command' })).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'write_file' })).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'unknown_tool' })).toBe(PolicyDecision.ALLOW);
// But explicitly excluded tools should still be denied
expect(engine.check({ name: 'dangerous-tool' }, undefined)).toBe(
expect(engine.check({ name: 'dangerous-tool' })).toBe(
PolicyDecision.DENY,
);
});
@@ -236,15 +220,11 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Edit tools should be allowed in AUTO_EDIT mode
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ALLOW);
expect(engine.check({ name: 'write_file' })).toBe(PolicyDecision.ALLOW);
// Other tools should follow normal rules
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
expect(engine.check({ name: 'run_shell_command' })).toBe(
PolicyDecision.ASK_USER,
);
});
@@ -305,24 +285,20 @@ describe('Policy Engine Integration Tests', () => {
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
// Verify the engine applies these priorities correctly
expect(engine.check({ name: 'blocked-tool' }, undefined)).toBe(
expect(engine.check({ name: 'blocked-tool' })).toBe(PolicyDecision.DENY);
expect(engine.check({ name: 'blocked-server__any' })).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'blocked-server__any' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'specific-tool' }, undefined)).toBe(
expect(engine.check({ name: 'specific-tool' })).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'trusted-server__any' }, undefined)).toBe(
expect(engine.check({ name: 'trusted-server__any' })).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'mcp-server__any' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'glob' }, undefined)).toBe(
expect(engine.check({ name: 'mcp-server__any' })).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'glob' })).toBe(PolicyDecision.ALLOW);
});
it('should handle edge case: MCP server with both trust and exclusion', async () => {
@@ -346,7 +322,7 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Exclusion (195) should win over trust (90)
expect(engine.check({ name: 'conflicted-server__tool' }, undefined)).toBe(
expect(engine.check({ name: 'conflicted-server__tool' })).toBe(
PolicyDecision.DENY,
);
});
@@ -369,10 +345,10 @@ describe('Policy Engine Integration Tests', () => {
// Server exclusion (195) wins over specific tool allow (100)
// This might be counterintuitive but follows the priority system
expect(engine.check({ name: 'my-server__special-tool' }, undefined)).toBe(
expect(engine.check({ name: 'my-server__special-tool' })).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'my-server__other-tool' }, undefined)).toBe(
expect(engine.check({ name: 'my-server__other-tool' })).toBe(
PolicyDecision.DENY,
);
});
@@ -389,10 +365,8 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(engineConfig);
// ASK_USER should become DENY in non-interactive mode
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
expect(engine.check({ name: 'unknown_tool' })).toBe(PolicyDecision.DENY);
expect(engine.check({ name: 'run_shell_command' })).toBe(
PolicyDecision.DENY,
);
});
@@ -407,17 +381,13 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Should have default rules for write tools
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(engine.check({ name: 'replace' }, undefined)).toBe(
expect(engine.check({ name: 'write_file' })).toBe(
PolicyDecision.ASK_USER,
);
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ASK_USER);
// Unknown tools should use default
expect(engine.check({ name: 'unknown' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(engine.check({ name: 'unknown' })).toBe(PolicyDecision.ASK_USER);
});
it('should verify rules are created with correct priorities', async () => {
@@ -0,0 +1,982 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApprovalMode, PolicyDecision } from '@google/gemini-cli-core';
import type { Dirent } from 'node:fs';
import nodePath from 'node:path';
describe('policy-toml-loader', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
vi.doUnmock('node:fs/promises');
});
describe('loadPoliciesFromToml', () => {
it('should load and parse a simple policy file', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'test.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'test.toml'))
) {
return `
[[rule]]
toolName = "glob"
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(1);
expect(result.rules[0]).toEqual({
toolName: 'glob',
decision: PolicyDecision.ALLOW,
priority: 1.1, // tier 1 + 100/1000
});
expect(result.errors).toHaveLength(0);
});
it('should expand commandPrefix array to multiple rules', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'shell.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'shell.toml'))
) {
return `
[[rule]]
toolName = "run_shell_command"
commandPrefix = ["git status", "git log"]
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 2;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(2);
expect(result.rules[0].toolName).toBe('run_shell_command');
expect(result.rules[1].toolName).toBe('run_shell_command');
expect(
result.rules[0].argsPattern?.test('{"command":"git status"}'),
).toBe(true);
expect(result.rules[1].argsPattern?.test('{"command":"git log"}')).toBe(
true,
);
expect(result.errors).toHaveLength(0);
});
it('should transform commandRegex to argsPattern', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'shell.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'shell.toml'))
) {
return `
[[rule]]
toolName = "run_shell_command"
commandRegex = "git (status|log).*"
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 2;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(1);
expect(
result.rules[0].argsPattern?.test('{"command":"git status"}'),
).toBe(true);
expect(
result.rules[0].argsPattern?.test('{"command":"git log --all"}'),
).toBe(true);
expect(
result.rules[0].argsPattern?.test('{"command":"git branch"}'),
).toBe(false);
expect(result.errors).toHaveLength(0);
});
it('should expand toolName array', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'tools.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'tools.toml'))
) {
return `
[[rule]]
toolName = ["glob", "grep", "read"]
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(3);
expect(result.rules.map((r) => r.toolName)).toEqual([
'glob',
'grep',
'read',
]);
expect(result.errors).toHaveLength(0);
});
it('should transform mcpName to composite toolName', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'mcp.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'mcp.toml'))
) {
return `
[[rule]]
mcpName = "google-workspace"
toolName = ["calendar.list", "calendar.get"]
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 2;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(2);
expect(result.rules[0].toolName).toBe('google-workspace__calendar.list');
expect(result.rules[1].toolName).toBe('google-workspace__calendar.get');
expect(result.errors).toHaveLength(0);
});
it('should filter rules by mode', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'modes.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'modes.toml'))
) {
return `
[[rule]]
toolName = "glob"
decision = "allow"
priority = 100
modes = ["default", "yolo"]
[[rule]]
toolName = "grep"
decision = "allow"
priority = 100
modes = ["yolo"]
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
// Only the first rule should be included (modes includes "default")
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('glob');
expect(result.errors).toHaveLength(0);
});
it('should handle TOML parse errors', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'invalid.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'invalid.toml'))
) {
return `
[[rule]
toolName = "glob"
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorType).toBe('toml_parse');
expect(result.errors[0].fileName).toBe('invalid.toml');
});
it('should handle schema validation errors', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'invalid.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'invalid.toml'))
) {
return `
[[rule]]
toolName = "glob"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorType).toBe('schema_validation');
expect(result.errors[0].details).toContain('decision');
});
it('should reject commandPrefix without run_shell_command', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'invalid.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'invalid.toml'))
) {
return `
[[rule]]
toolName = "glob"
commandPrefix = "git status"
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorType).toBe('rule_validation');
expect(result.errors[0].details).toContain('run_shell_command');
});
it('should reject commandPrefix + argsPattern combination', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'invalid.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'invalid.toml'))
) {
return `
[[rule]]
toolName = "run_shell_command"
commandPrefix = "git status"
argsPattern = "test"
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorType).toBe('rule_validation');
expect(result.errors[0].details).toContain('mutually exclusive');
});
it('should handle invalid regex patterns', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'invalid.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'invalid.toml'))
) {
return `
[[rule]]
toolName = "run_shell_command"
commandRegex = "git (status|branch"
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorType).toBe('regex_compilation');
expect(result.errors[0].details).toContain('git (status|branch');
});
it('should escape regex special characters in commandPrefix', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'shell.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'shell.toml'))
) {
return `
[[rule]]
toolName = "run_shell_command"
commandPrefix = "git log *.txt"
decision = "allow"
priority = 100
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(1);
// Should match literal asterisk, not wildcard
expect(
result.rules[0].argsPattern?.test('{"command":"git log *.txt"}'),
).toBe(true);
expect(
result.rules[0].argsPattern?.test('{"command":"git log a.txt"}'),
).toBe(false);
expect(result.errors).toHaveLength(0);
});
it('should handle non-existent directory gracefully', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(async (_path: string): Promise<Dirent[]> => {
const error: NodeJS.ErrnoException = new Error('ENOENT');
error.code = 'ENOENT';
throw error;
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readdir: mockReaddir },
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/non-existent'],
getPolicyTier,
);
// Should not error for missing directories
expect(result.rules).toHaveLength(0);
expect(result.errors).toHaveLength(0);
});
it('should reject priority >= 1000 with helpful error message', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'invalid.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'invalid.toml'))
) {
return `
[[rule]]
toolName = "glob"
decision = "allow"
priority = 1000
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorType).toBe('schema_validation');
expect(result.errors[0].details).toContain('priority');
expect(result.errors[0].details).toContain('tier overflow');
expect(result.errors[0].details).toContain(
'Priorities >= 1000 would jump to the next tier',
);
expect(result.errors[0].details).toContain('<= 999');
});
it('should reject negative priority with helpful error message', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
if (nodePath.normalize(path) === nodePath.normalize('/policies')) {
return [
{
name: 'invalid.toml',
isFile: () => true,
isDirectory: () => false,
} as Dirent,
];
}
return [];
},
);
const mockReadFile = vi.fn(async (path: string): Promise<string> => {
if (
nodePath.normalize(path) ===
nodePath.normalize(nodePath.join('/policies', 'invalid.toml'))
) {
return `
[[rule]]
toolName = "glob"
decision = "allow"
priority = -1
`;
}
throw new Error('File not found');
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
readFile: mockReadFile,
readdir: mockReaddir,
}));
const { loadPoliciesFromToml: load } = await import(
'./policy-toml-loader.js'
);
const getPolicyTier = (_dir: string) => 1;
const result = await load(
ApprovalMode.DEFAULT,
['/policies'],
getPolicyTier,
);
expect(result.rules).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorType).toBe('schema_validation');
expect(result.errors[0].details).toContain('priority');
expect(result.errors[0].details).toContain('>= 0');
expect(result.errors[0].details).toContain('must be >= 0');
});
});
});
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type PolicyRule, PolicyDecision, type ApprovalMode } from './types.js';
import {
type PolicyRule,
PolicyDecision,
type ApprovalMode,
} from '@google/gemini-cli-core';
import fs from 'node:fs/promises';
import path from 'node:path';
import toml from '@iarna/toml';
File diff suppressed because it is too large Load Diff
+217 -13
View File
@@ -6,33 +6,237 @@
import {
type PolicyEngineConfig,
PolicyDecision,
type PolicyRule,
type ApprovalMode,
type PolicyEngine,
type MessageBus,
type PolicySettings,
createPolicyEngineConfig as createCorePolicyEngineConfig,
createPolicyUpdater as createCorePolicyUpdater,
MessageBusType,
type UpdatePolicy,
Storage,
} from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
import { type Settings, getSystemSettingsPath } from './settings.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
loadPoliciesFromToml,
type PolicyFileError,
} from './policy-toml-loader.js';
// Get the directory name of the current module
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Store policy loading errors to be displayed after UI is ready
let storedPolicyErrors: string[] = [];
function getPolicyDirectories(): string[] {
const DEFAULT_POLICIES_DIR = path.resolve(__dirname, 'policies');
const USER_POLICIES_DIR = Storage.getUserPoliciesDir();
const systemSettingsPath = getSystemSettingsPath();
const ADMIN_POLICIES_DIR = path.join(
path.dirname(systemSettingsPath),
'policies',
);
return [
DEFAULT_POLICIES_DIR,
USER_POLICIES_DIR,
ADMIN_POLICIES_DIR,
].reverse();
}
/**
* Determines the policy tier (1=default, 2=user, 3=admin) for a given directory.
* This is used by the TOML loader to assign priority bands.
*/
function getPolicyTier(dir: string): number {
const DEFAULT_POLICIES_DIR = path.resolve(__dirname, 'policies');
const USER_POLICIES_DIR = Storage.getUserPoliciesDir();
const systemSettingsPath = getSystemSettingsPath();
const ADMIN_POLICIES_DIR = path.join(
path.dirname(systemSettingsPath),
'policies',
);
// Normalize paths for comparison
const normalizedDir = path.resolve(dir);
const normalizedDefault = path.resolve(DEFAULT_POLICIES_DIR);
const normalizedUser = path.resolve(USER_POLICIES_DIR);
const normalizedAdmin = path.resolve(ADMIN_POLICIES_DIR);
if (normalizedDir === normalizedDefault) return 1;
if (normalizedDir === normalizedUser) return 2;
if (normalizedDir === normalizedAdmin) return 3;
// Default to tier 1 if unknown
return 1;
}
/**
* Formats a policy file error for console logging.
*/
function formatPolicyError(error: PolicyFileError): string {
const tierLabel = error.tier.toUpperCase();
let message = `[${tierLabel}] Policy file error in ${error.fileName}:\n`;
message += ` ${error.message}`;
if (error.details) {
message += `\n${error.details}`;
}
if (error.suggestion) {
message += `\n Suggestion: ${error.suggestion}`;
}
return message;
}
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
): Promise<PolicyEngineConfig> {
// Explicitly construct PolicySettings from Settings to ensure type safety
// and avoid accidental leakage of other settings properties.
const policySettings: PolicySettings = {
mcp: settings.mcp,
tools: settings.tools,
mcpServers: settings.mcpServers,
};
const policyDirs = getPolicyDirectories();
return createCorePolicyEngineConfig(policySettings, approvalMode);
// Load policies from TOML files
const { rules: tomlRules, errors } = await loadPoliciesFromToml(
approvalMode,
policyDirs,
getPolicyTier,
);
// Store any errors encountered during TOML loading
// These will be emitted by getPolicyErrorsForUI() after the UI is ready.
if (errors.length > 0) {
storedPolicyErrors = errors.map((error) => formatPolicyError(error));
}
const rules: PolicyRule[] = [...tomlRules];
// Priority system for policy rules:
// - Higher priority numbers win over lower priority numbers
// - When multiple rules match, the highest priority rule is applied
// - Rules are evaluated in order of priority (highest first)
//
// Priority bands (tiers):
// - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
// - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
// - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
//
// This ensures Admin > User > Default hierarchy is always preserved,
// while allowing user-specified priorities to work within each tier.
//
// Settings-based and dynamic rules (all in user tier 2.x):
// 2.95: Tools that the user has selected as "Always Allow" in the interactive UI
// 2.9: MCP servers excluded list (security: persistent server blocks)
// 2.4: Command line flag --exclude-tools (explicit temporary blocks)
// 2.3: Command line flag --allowed-tools (explicit temporary allows)
// 2.2: MCP servers with trust=true (persistent trusted servers)
// 2.1: MCP servers allowed list (persistent general server allows)
//
// TOML policy priorities (before transformation):
// 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
// 15: Auto-edit tool override (becomes 1.015 in default tier)
// 50: Read-only tools (becomes 1.050 in default tier)
// 999: YOLO mode allow-all (becomes 1.999 in default tier)
// MCP servers that are explicitly excluded in settings.mcp.excluded
// Priority: 2.9 (highest in user tier for security - persistent server blocks)
if (settings.mcp?.excluded) {
for (const serverName of settings.mcp.excluded) {
rules.push({
toolName: `${serverName}__*`,
decision: PolicyDecision.DENY,
priority: 2.9,
});
}
}
// Tools that are explicitly excluded in the settings.
// Priority: 2.4 (user tier - explicit temporary blocks)
if (settings.tools?.exclude) {
for (const tool of settings.tools.exclude) {
rules.push({
toolName: tool,
decision: PolicyDecision.DENY,
priority: 2.4,
});
}
}
// Tools that are explicitly allowed in the settings.
// Priority: 2.3 (user tier - explicit temporary allows)
if (settings.tools?.allowed) {
for (const tool of settings.tools.allowed) {
rules.push({
toolName: tool,
decision: PolicyDecision.ALLOW,
priority: 2.3,
});
}
}
// MCP servers that are trusted in the settings.
// Priority: 2.2 (user tier - persistent trusted servers)
if (settings.mcpServers) {
for (const [serverName, serverConfig] of Object.entries(
settings.mcpServers,
)) {
if (serverConfig.trust) {
// Trust all tools from this MCP server
// Using pattern matching for MCP tool names which are formatted as "serverName__toolName"
rules.push({
toolName: `${serverName}__*`,
decision: PolicyDecision.ALLOW,
priority: 2.2,
});
}
}
}
// MCP servers that are explicitly allowed in settings.mcp.allowed
// Priority: 2.1 (user tier - persistent general server allows)
if (settings.mcp?.allowed) {
for (const serverName of settings.mcp.allowed) {
rules.push({
toolName: `${serverName}__*`,
decision: PolicyDecision.ALLOW,
priority: 2.1,
});
}
}
return {
rules,
defaultDecision: PolicyDecision.ASK_USER,
};
}
export function createPolicyUpdater(
policyEngine: PolicyEngine,
messageBus: MessageBus,
) {
return createCorePolicyUpdater(policyEngine, messageBus);
messageBus.subscribe(
MessageBusType.UPDATE_POLICY,
(message: UpdatePolicy) => {
const toolName = message.toolName;
policyEngine.addRule({
toolName,
decision: PolicyDecision.ALLOW,
// User tier (2) + high priority (950/1000) = 2.95
// This ensures user "always allow" selections are high priority
// but still lose to admin policies (3.xxx) and settings excludes (200)
priority: 2.95,
});
},
);
}
/**
* Gets and clears any policy errors that were stored during config loading.
* This should be called once the UI is ready to display errors.
*
* @returns Array of formatted error messages, or empty array if no errors
*/
export function getPolicyErrorsForUI(): string[] {
const errors = [...storedPolicyErrors];
storedPolicyErrors = []; // Clear after retrieving
return errors;
}
+5 -11
View File
@@ -4,25 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
getPackageJson,
type SandboxConfig,
FatalSandboxError,
} from '@google/gemini-cli-core';
import type { SandboxConfig } from '@google/gemini-cli-core';
import { FatalSandboxError } from '@google/gemini-cli-core';
import commandExists from 'command-exists';
import * as os from 'node:os';
import { getPackageJson } from '../utils/package.js';
import type { Settings } from './settings.js';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// This is a stripped-down version of the CliArgs interface from config.ts
// to avoid circular dependencies.
interface SandboxCliArgs {
sandbox?: boolean | string;
}
const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'docker',
'podman',
@@ -100,7 +94,7 @@ export async function loadSandboxConfig(
const sandboxOption = argv.sandbox ?? settings.tools?.sandbox;
const command = getSandboxCommand(sandboxOption);
const packageJson = await getPackageJson(__dirname);
const packageJson = await getPackageJson();
const image =
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
-11
View File
@@ -1,11 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const SettingPaths = {
General: {
PreferredEditor: 'general.preferredEditor',
},
} as const;
+59 -15
View File
@@ -1104,15 +1104,15 @@ describe('Settings Loading and Merging', () => {
});
});
it('should merge compressionThreshold settings, with workspace taking precedence', () => {
it('should merge chatCompression settings, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
general: {},
model: { compressionThreshold: 0.5 },
model: { chatCompression: { contextPercentageThreshold: 0.5 } },
};
const workspaceSettingsContent = {
general: {},
model: { compressionThreshold: 0.8 },
model: { chatCompression: { contextPercentageThreshold: 0.8 } },
};
(fs.readFileSync as Mock).mockImplementation(
@@ -1127,11 +1127,15 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.model?.compressionThreshold).toEqual(0.5);
expect(settings.workspace.settings.model?.compressionThreshold).toEqual(
0.8,
);
expect(settings.merged.model?.compressionThreshold).toEqual(0.8);
expect(settings.user.settings.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
expect(settings.workspace.settings.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.8,
});
expect(settings.merged.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.8,
});
});
it('should merge output format settings, with workspace taking precedence', () => {
@@ -1158,13 +1162,13 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.output?.format).toBe('json');
});
it('should handle compressionThreshold when only in user settings', () => {
it('should handle chatCompression when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
general: {},
model: { compressionThreshold: 0.5 },
model: { chatCompression: { contextPercentageThreshold: 0.5 } },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
@@ -1175,7 +1179,9 @@ describe('Settings Loading and Merging', () => {
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.compressionThreshold).toEqual(0.5);
expect(settings.merged.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
});
it('should have model as undefined if not in any settings file', () => {
@@ -1185,15 +1191,39 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.model).toBeUndefined();
});
it('should use user compressionThreshold if workspace does not define it', () => {
it('should ignore chatCompression if contextPercentageThreshold is invalid', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
general: {},
model: { chatCompression: { contextPercentageThreshold: 1.5 } },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.chatCompression).toEqual({
contextPercentageThreshold: 1.5,
});
warnSpy.mockRestore();
});
it('should deep merge chatCompression settings', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
general: {},
model: { compressionThreshold: 0.5 },
model: { chatCompression: { contextPercentageThreshold: 0.5 } },
};
const workspaceSettingsContent = {
general: {},
model: {},
model: { chatCompression: {} },
};
(fs.readFileSync as Mock).mockImplementation(
@@ -1208,7 +1238,9 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.compressionThreshold).toEqual(0.5);
expect(settings.merged.model?.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
});
it('should merge includeDirectories from all scopes', () => {
@@ -1993,6 +2025,9 @@ describe('Settings Loading and Merging', () => {
},
model: {
name: 'gemini-pro',
chatCompression: {
contextPercentageThreshold: 0.5,
},
},
mcpServers: {
'server-1': {
@@ -2011,6 +2046,9 @@ describe('Settings Loading and Merging', () => {
myTheme: {},
},
model: 'gemini-pro',
chatCompression: {
contextPercentageThreshold: 0.5,
},
mcpServers: {
'server-1': {
command: 'node server.js',
@@ -2050,6 +2088,9 @@ describe('Settings Loading and Merging', () => {
},
model: {
name: 'gemini-pro',
chatCompression: {
contextPercentageThreshold: 0.8,
},
},
context: {
fileName: 'CONTEXT.md',
@@ -2089,6 +2130,9 @@ describe('Settings Loading and Merging', () => {
theme: 'dark',
usageStatisticsEnabled: false,
model: 'gemini-pro',
chatCompression: {
contextPercentageThreshold: 0.8,
},
contextFileName: 'CONTEXT.md',
includeDirectories: ['/src'],
sandbox: true,
+16 -50
View File
@@ -33,7 +33,6 @@ import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import type { ExtensionManager } from './extension-manager.js';
import { SettingPaths } from './settingPaths.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@@ -65,7 +64,7 @@ const MIGRATION_MAP: Record<string, string> = {
autoAccept: 'tools.autoAccept',
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
bugCommand: 'advanced.bugCommand',
chatCompression: 'model.compressionThreshold',
chatCompression: 'model.chatCompression',
checkpointing: 'general.checkpointing',
coreTools: 'tools.core',
contextFileName: 'context.fileName',
@@ -76,7 +75,6 @@ const MIGRATION_MAP: Record<string, string> = {
disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enableMessageBusIntegration: 'tools.enableMessageBusIntegration',
enableHooks: 'tools.enableHooks',
enablePromptCompletion: 'general.enablePromptCompletion',
enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude',
@@ -109,7 +107,7 @@ const MIGRATION_MAP: Record<string, string> = {
memoryImportFormat: 'context.importFormat',
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
model: 'model.name',
preferredEditor: SettingPaths.General.PreferredEditor,
preferredEditor: 'general.preferredEditor',
retryFetchErrors: 'general.retryFetchErrors',
sandbox: 'tools.sandbox',
selectedAuthType: 'security.auth.selectedType',
@@ -158,38 +156,6 @@ export enum SettingScope {
Workspace = 'Workspace',
System = 'System',
SystemDefaults = 'SystemDefaults',
// Note that this scope is not supported in the settings dialog at this time,
// it is only supported for extensions.
Session = 'Session',
}
/**
* A type representing the settings scopes that are supported for LoadedSettings.
*/
export type LoadableSettingScope =
| SettingScope.User
| SettingScope.Workspace
| SettingScope.System
| SettingScope.SystemDefaults;
/**
* The actual values of the loadable settings scopes.
*/
const _loadableSettingScopes = [
SettingScope.User,
SettingScope.Workspace,
SettingScope.System,
SettingScope.SystemDefaults,
];
/**
* A type guard function that checks if `scope` is a loadable settings scope,
* and allows promotion to the `LoadableSettingsScope` type based on the result.
*/
export function isLoadableSettingScope(
scope: SettingScope,
): scope is LoadableSettingScope {
return _loadableSettingScopes.includes(scope);
}
export interface CheckpointingSettings {
@@ -431,14 +397,14 @@ export class LoadedSettings {
user: SettingsFile,
workspace: SettingsFile,
isTrusted: boolean,
migratedInMemoryScopes: Set<SettingScope>,
migratedInMemorScopes: Set<SettingScope>,
) {
this.system = system;
this.systemDefaults = systemDefaults;
this.user = user;
this.workspace = workspace;
this.isTrusted = isTrusted;
this.migratedInMemoryScopes = migratedInMemoryScopes;
this.migratedInMemorScopes = migratedInMemorScopes;
this._merged = this.computeMergedSettings();
}
@@ -447,7 +413,7 @@ export class LoadedSettings {
readonly user: SettingsFile;
readonly workspace: SettingsFile;
readonly isTrusted: boolean;
readonly migratedInMemoryScopes: Set<SettingScope>;
readonly migratedInMemorScopes: Set<SettingScope>;
private _merged: Settings;
@@ -465,7 +431,7 @@ export class LoadedSettings {
);
}
forScope(scope: LoadableSettingScope): SettingsFile {
forScope(scope: SettingScope): SettingsFile {
switch (scope) {
case SettingScope.User:
return this.user;
@@ -480,7 +446,7 @@ export class LoadedSettings {
}
}
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
setValue(scope: SettingScope, key: string, value: unknown): void {
const settingsFile = this.forScope(scope);
setNestedProperty(settingsFile.settings, key, value);
setNestedProperty(settingsFile.originalSettings, key, value);
@@ -596,7 +562,7 @@ export function loadSettings(
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
const migratedInMemoryScopes = new Set<SettingScope>();
const migratedInMemorScopes = new Set<SettingScope>();
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
@@ -651,14 +617,14 @@ export function loadSettings(
'utf-8',
);
} catch (e) {
coreEvents.emitFeedback(
'error',
'Failed to migrate settings file.',
e,
console.error(
`Error migrating settings file on disk: ${getErrorMessage(
e,
)}`,
);
}
} else {
migratedInMemoryScopes.add(scope);
migratedInMemorScopes.add(scope);
}
settingsObject = migratedSettings;
}
@@ -736,7 +702,7 @@ export function loadSettings(
isTrusted,
);
// loadEnvironment depends on settings so we have to create a temp version of
// loadEnviroment depends on settings so we have to create a temp version of
// the settings to avoid a cycle
loadEnvironment(tempMergedSettings);
@@ -777,7 +743,7 @@ export function loadSettings(
rawJson: workspaceResult.rawJson,
},
isTrusted,
migratedInMemoryScopes,
migratedInMemorScopes,
);
}
@@ -785,7 +751,7 @@ export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
extensionManager: ExtensionManager,
): void {
const processScope = (scope: LoadableSettingScope) => {
const processScope = (scope: SettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
if (settings.extensions?.disabled) {
debugLogger.log(
@@ -7,8 +7,6 @@
import { describe, it, expect } from 'vitest';
import {
getSettingsSchema,
SETTINGS_SCHEMA_DEFINITIONS,
type SettingCollectionDefinition,
type SettingDefinition,
type Settings,
type SettingsSchema,
@@ -161,10 +159,6 @@ describe('SettingsSchema', () => {
expect(
getSettingsSchema().ui.properties.showMemoryUsage.showInDialog,
).toBe(true);
expect(
getSettingsSchema().ui.properties.footer.properties
.hideContextPercentage.showInDialog,
).toBe(true);
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
true,
);
@@ -337,50 +331,4 @@ describe('SettingsSchema', () => {
).toBe(true);
});
});
it('has JSON schema definitions for every referenced ref', () => {
const schema = getSettingsSchema();
const referenced = new Set<string>();
const visitDefinition = (definition: SettingDefinition) => {
if (definition.ref) {
referenced.add(definition.ref);
expect(SETTINGS_SCHEMA_DEFINITIONS).toHaveProperty(definition.ref);
}
if (definition.properties) {
Object.values(definition.properties).forEach(visitDefinition);
}
if (definition.items) {
visitCollection(definition.items);
}
if (definition.additionalProperties) {
visitCollection(definition.additionalProperties);
}
};
const visitCollection = (collection: SettingCollectionDefinition) => {
if (collection.ref) {
referenced.add(collection.ref);
expect(SETTINGS_SCHEMA_DEFINITIONS).toHaveProperty(collection.ref);
return;
}
if (collection.properties) {
Object.values(collection.properties).forEach(visitDefinition);
}
if (collection.type === 'array' && collection.properties) {
Object.values(collection.properties).forEach(visitDefinition);
}
};
Object.values(schema).forEach(visitDefinition);
// Ensure definitions map doesn't accumulate stale entries.
Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => {
if (!referenced.has(key)) {
throw new Error(
`Definition "${key}" is exported but never referenced in the schema`,
);
}
});
});
});
+39 -508
View File
@@ -5,8 +5,8 @@
*/
// --------------------------------------------------------------------------
// IMPORTANT: After adding or updating settings, run `npm run docs:settings`
// to regenerate the settings reference in `docs/get-started/configuration.md`.
// IMPORTANT: When adding a new setting, especially one with `showInDialog: true`,
// please ensure it is also documented in `docs/get-started/configuration.md`.
// --------------------------------------------------------------------------
import type {
@@ -14,14 +14,12 @@ import type {
BugCommandSettings,
TelemetrySettings,
AuthType,
HookDefinition,
HookEventName,
ChatCompressionSettings,
} from '@google/gemini-cli-core';
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_GEMINI_MODEL,
DEFAULT_MODEL_CONFIGS,
} from '@google/gemini-cli-core';
import type { CustomTheme } from '../ui/themes/theme.js';
import type { SessionRetentionSettings } from './settings.js';
@@ -58,30 +56,6 @@ export interface SettingEnumOption {
label: string;
}
function oneLine(strings: TemplateStringsArray, ...values: unknown[]): string {
let result = '';
for (let i = 0; i < strings.length; i++) {
result += strings[i];
if (i < values.length) {
result += String(values[i]);
}
}
return result.replace(/\s+/g, ' ').trim();
}
export interface SettingCollectionDefinition {
type: SettingsType;
description?: string;
properties?: SettingsSchema;
/** Enum type options */
options?: readonly SettingEnumOption[];
/**
* Optional reference identifier for generators that emit a `$ref`.
* For example, a JSON schema generator can use this to point to a shared definition.
*/
ref?: string;
}
export enum MergeStrategy {
// Replace the old value with the new value. This is the default.
REPLACE = 'replace',
@@ -108,18 +82,6 @@ export interface SettingDefinition {
mergeStrategy?: MergeStrategy;
/** Enum type options */
options?: readonly SettingEnumOption[];
/**
* For collection types (e.g. arrays), describes the shape of each item.
*/
items?: SettingCollectionDefinition;
/**
* For map-like objects without explicit `properties`, describes the shape of the values.
*/
additionalProperties?: SettingCollectionDefinition;
/**
* Optional reference identifier for generators that emit a `$ref`.
*/
ref?: string;
}
export interface SettingsSchema {
@@ -145,10 +107,6 @@ const SETTINGS_SCHEMA = {
description: 'Configuration for MCP servers.',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
additionalProperties: {
type: 'object',
ref: 'MCPServerConfig',
},
},
general: {
@@ -335,8 +293,7 @@ const SETTINGS_SCHEMA = {
category: 'UI',
requiresRestart: false,
default: undefined as string | undefined,
description:
'The color theme for the UI. See the CLI themes guide for available options.',
description: 'The color theme for the UI.',
showInDialog: false,
},
customThemes: {
@@ -347,10 +304,6 @@ const SETTINGS_SCHEMA = {
default: {} as Record<string, CustomTheme>,
description: 'Custom theme definitions.',
showInDialog: false,
additionalProperties: {
type: 'object',
ref: 'CustomTheme',
},
},
hideWindowTitle: {
type: 'boolean',
@@ -436,15 +389,6 @@ const SETTINGS_SCHEMA = {
description: 'Hide the model name and context usage in the footer.',
showInDialog: true,
},
hideContextPercentage: {
type: 'boolean',
label: 'Hide Context Window Percentage',
category: 'UI',
requiresRestart: false,
default: true,
description: 'Hides the context window remaining percentage.',
showInDialog: true,
},
},
},
hideFooter: {
@@ -488,42 +432,18 @@ const SETTINGS_SCHEMA = {
label: 'Use Full Width',
category: 'UI',
requiresRestart: false,
default: true,
default: false,
description: 'Use the entire width of the terminal for output.',
showInDialog: true,
},
useAlternateBuffer: {
type: 'boolean',
label: 'Use Alternate Screen Buffer',
category: 'UI',
requiresRestart: true,
default: true,
description:
'Use an alternate screen buffer for the UI, preserving shell history.',
showInDialog: true,
},
incrementalRendering: {
type: 'boolean',
label: 'Incremental Rendering',
category: 'UI',
requiresRestart: true,
default: true,
description:
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
showInDialog: true,
},
customWittyPhrases: {
type: 'array',
label: 'Custom Witty Phrases',
category: 'UI',
requiresRestart: false,
default: [] as string[],
description: oneLine`
Custom witty phrases to display during loading.
When provided, the CLI cycles through these instead of the defaults.
`,
description: 'Custom witty phrases to display during loading.',
showInDialog: false,
items: { type: 'string' },
},
accessibility: {
type: 'object',
@@ -617,7 +537,6 @@ const SETTINGS_SCHEMA = {
default: undefined as TelemetrySettings | undefined,
description: 'Telemetry configuration.',
showInDialog: false,
ref: 'TelemetrySettings',
},
model: {
@@ -656,28 +575,17 @@ const SETTINGS_SCHEMA = {
default: undefined as
| Record<string, { tokenBudget?: number }>
| undefined,
description: oneLine`
Enables or disables summarization of tool output.
Configure per-tool token budgets (for example {"run_shell_command": {"tokenBudget": 2000}}).
Currently only the run_shell_command tool supports summarization.
`,
description: 'Settings for summarizing tool output.',
showInDialog: false,
additionalProperties: {
type: 'object',
description:
'Per-tool summarization settings with an optional tokenBudget.',
ref: 'SummarizeToolOutputSettings',
},
},
compressionThreshold: {
type: 'number',
label: 'Compression Threshold',
chatCompression: {
type: 'object',
label: 'Chat Compression',
category: 'Model',
requiresRestart: true,
default: 0.2 as number,
description:
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true,
requiresRestart: false,
default: undefined as ChatCompressionSettings | undefined,
description: 'Chat compression settings.',
showInDialog: false,
},
skipNextSpeakerCheck: {
type: 'boolean',
@@ -691,38 +599,6 @@ const SETTINGS_SCHEMA = {
},
},
modelConfigs: {
type: 'object',
label: 'Model Configs',
category: 'Model',
requiresRestart: false,
default: DEFAULT_MODEL_CONFIGS,
description: 'Model configurations.',
showInDialog: false,
properties: {
aliases: {
type: 'object',
label: 'Model Config Aliases',
category: 'Model',
requiresRestart: false,
default: DEFAULT_MODEL_CONFIGS.aliases,
description:
'Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.',
showInDialog: false,
},
overrides: {
type: 'array',
label: 'Model Config Overrides',
category: 'Model',
requiresRestart: false,
default: [],
description:
'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.',
showInDialog: false,
},
},
},
context: {
type: 'object',
label: 'Context',
@@ -733,14 +609,12 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
properties: {
fileName: {
type: 'string',
type: 'object',
label: 'Context File Name',
category: 'Context',
requiresRestart: false,
default: undefined as string | string[] | undefined,
ref: 'StringOrStringArray',
description:
'The name of the context file or files to load into memory. Accepts either a single string or an array of strings.',
description: 'The name of the context file.',
showInDialog: false,
},
importFormat: {
@@ -767,12 +641,9 @@ const SETTINGS_SCHEMA = {
category: 'Context',
requiresRestart: false,
default: [] as string[],
description: oneLine`
Additional directories to include in the workspace context.
Missing directories will be skipped with a warning.
`,
description:
'Additional directories to include in the workspace context. Missing directories will be skipped with a warning.',
showInDialog: false,
items: { type: 'string' },
mergeStrategy: MergeStrategy.CONCAT,
},
loadMemoryFromIncludeDirectories: {
@@ -781,10 +652,7 @@ const SETTINGS_SCHEMA = {
category: 'Context',
requiresRestart: false,
default: false,
description: oneLine`
Controls how /memory refresh loads GEMINI.md files.
When true, include directories are scanned; when false, only the current directory is used.
`,
description: 'Whether to load memory files from include directories.',
showInDialog: true,
},
fileFiltering: {
@@ -820,9 +688,7 @@ const SETTINGS_SCHEMA = {
category: 'Context',
requiresRestart: true,
default: true,
description: oneLine`
Enable recursive file search functionality when completing @ references in the prompt.
`,
description: 'Enable recursive file search functionality',
showInDialog: true,
},
disableFuzzySearch: {
@@ -849,16 +715,13 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
properties: {
sandbox: {
type: 'string',
type: 'object',
label: 'Sandbox',
category: 'Tools',
requiresRestart: true,
default: undefined as boolean | string | undefined,
ref: 'BooleanOrString',
description: oneLine`
Sandbox execution environment.
Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile.
`,
description:
'Sandbox execution environment (can be a boolean or a path string).',
showInDialog: false,
},
shell: {
@@ -876,10 +739,8 @@ const SETTINGS_SCHEMA = {
category: 'Tools',
requiresRestart: true,
default: true,
description: oneLine`
Use node-pty for an interactive shell experience.
Fallback to child_process still applies.
`,
description:
'Use node-pty for an interactive shell experience. Fallback to child_process still applies.',
showInDialog: true,
},
pager: {
@@ -909,9 +770,8 @@ const SETTINGS_SCHEMA = {
category: 'Tools',
requiresRestart: false,
default: false,
description: oneLine`
Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).
`,
description:
'Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).',
showInDialog: true,
},
core: {
@@ -920,12 +780,8 @@ const SETTINGS_SCHEMA = {
category: 'Tools',
requiresRestart: true,
default: undefined as string[] | undefined,
description: oneLine`
Restrict the set of built-in tools with an allowlist.
Match semantics mirror tools.allowed; see the built-in tools documentation for available names.
`,
description: 'Paths to core tool definitions.',
showInDialog: false,
items: { type: 'string' },
},
allowed: {
type: 'array',
@@ -933,13 +789,9 @@ const SETTINGS_SCHEMA = {
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: oneLine`
Tool names that bypass the confirmation dialog.
Useful for trusted commands (for example ["run_shell_command(git)", "run_shell_command(npm test)"]).
See shell tool command restrictions for matching details.
`,
description:
'A list of tool names that will bypass the confirmation dialog.',
showInDialog: false,
items: { type: 'string' },
},
exclude: {
type: 'array',
@@ -949,7 +801,6 @@ const SETTINGS_SCHEMA = {
default: undefined as string[] | undefined,
description: 'Tool names to exclude from discovery.',
showInDialog: false,
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},
discoveryCommand: {
@@ -967,10 +818,7 @@ const SETTINGS_SCHEMA = {
category: 'Tools',
requiresRestart: true,
default: undefined as string | undefined,
description: oneLine`
Defines a custom shell command for invoking discovered tools.
The command must take the tool name as the first argument, read JSON arguments from stdin, and emit JSON results on stdout.
`,
description: 'Command to run for tool calls.',
showInDialog: false,
},
useRipgrep: {
@@ -1017,21 +865,9 @@ const SETTINGS_SCHEMA = {
category: 'Tools',
requiresRestart: true,
default: false,
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',
category: 'Advanced',
requiresRestart: true,
default: false,
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.',
showInDialog: false,
'Enable policy-based tool confirmation via message bus integration. When enabled, tools will automatically respect policy engine decisions (ALLOW/DENY/ASK_USER) without requiring individual tool implementations.',
showInDialog: true,
},
},
},
@@ -1062,7 +898,6 @@ const SETTINGS_SCHEMA = {
default: undefined as string[] | undefined,
description: 'A list of MCP servers to allow.',
showInDialog: false,
items: { type: 'string' },
},
excluded: {
type: 'array',
@@ -1072,7 +907,6 @@ const SETTINGS_SCHEMA = {
default: undefined as string[] | undefined,
description: 'A list of MCP servers to exclude.',
showInDialog: false,
items: { type: 'string' },
},
},
},
@@ -1090,8 +924,8 @@ const SETTINGS_SCHEMA = {
label: 'Use Write Todos',
category: 'Advanced',
requiresRestart: false,
default: true,
description: 'Enable the write_todos tool.',
default: false,
description: 'Enable the write_todos_list tool.',
showInDialog: false,
},
security: {
@@ -1112,15 +946,6 @@ const SETTINGS_SCHEMA = {
description: 'Disable YOLO mode, even if enabled by a flag.',
showInDialog: true,
},
blockGitExtensions: {
type: 'boolean',
label: 'Blocks extensions from Git',
category: 'Security',
requiresRestart: true,
default: false,
description: 'Blocks installing and loading extensions from Git.',
showInDialog: true,
},
folderTrust: {
type: 'object',
label: 'Folder Trust',
@@ -1218,7 +1043,6 @@ const SETTINGS_SCHEMA = {
default: ['DEBUG', 'DEBUG_MODE'] as string[],
description: 'Environment variables to exclude from project context.',
showInDialog: false,
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},
bugCommand: {
@@ -1229,7 +1053,6 @@ const SETTINGS_SCHEMA = {
default: undefined as BugCommandSettings | undefined,
description: 'Configuration for the bug report command.',
showInDialog: false,
ref: 'BugCommandSettings',
},
},
},
@@ -1252,16 +1075,6 @@ const SETTINGS_SCHEMA = {
description: 'Enable extension management features.',
showInDialog: false,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enables extension loading/unloading within the CLI session.',
showInDialog: false,
},
useModelRouter: {
type: 'boolean',
label: 'Use Model Router',
@@ -1286,7 +1099,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Codebase Investigator',
category: 'Experimental',
requiresRestart: true,
default: true,
default: false,
description: 'Enable the Codebase Investigator agent.',
showInDialog: true,
},
@@ -1295,7 +1108,7 @@ const SETTINGS_SCHEMA = {
label: 'Codebase Investigator Max Num Turns',
category: 'Experimental',
requiresRestart: true,
default: 10,
default: 15,
description:
'Maximum number of turns for the Codebase Investigator agent.',
showInDialog: true,
@@ -1305,7 +1118,7 @@ const SETTINGS_SCHEMA = {
label: 'Max Time (Minutes)',
category: 'Experimental',
requiresRestart: true,
default: 3,
default: 5,
description:
'Maximum time for the Codebase Investigator agent (in minutes).',
showInDialog: false,
@@ -1315,7 +1128,7 @@ const SETTINGS_SCHEMA = {
label: 'Thinking Budget',
category: 'Experimental',
requiresRestart: true,
default: 8192,
default: -1,
description:
'The thinking budget for the Codebase Investigator agent.',
showInDialog: false,
@@ -1352,7 +1165,6 @@ const SETTINGS_SCHEMA = {
default: [] as string[],
description: 'List of disabled extensions.',
showInDialog: false,
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},
workspacesWithMigrationNudge: {
@@ -1364,295 +1176,14 @@ const SETTINGS_SCHEMA = {
description:
'List of workspaces for which the migration nudge has been shown.',
showInDialog: false,
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},
},
},
hooks: {
type: 'object',
label: 'Hooks',
category: 'Advanced',
requiresRestart: false,
default: {} as { [K in HookEventName]?: HookDefinition[] },
description:
'Hook configurations for intercepting and customizing agent behavior.',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
export type SettingsJsonSchemaDefinition = Record<string, unknown>;
export const SETTINGS_SCHEMA_DEFINITIONS: Record<
string,
SettingsJsonSchemaDefinition
> = {
MCPServerConfig: {
type: 'object',
description:
'Definition of a Model Context Protocol (MCP) server configuration.',
additionalProperties: false,
properties: {
command: {
type: 'string',
description: 'Executable invoked for stdio transport.',
},
args: {
type: 'array',
description: 'Command-line arguments for the stdio transport command.',
items: { type: 'string' },
},
env: {
type: 'object',
description: 'Environment variables to set for the server process.',
additionalProperties: { type: 'string' },
},
cwd: {
type: 'string',
description: 'Working directory for the server process.',
},
url: {
type: 'string',
description: 'SSE transport URL.',
},
httpUrl: {
type: 'string',
description: 'Streaming HTTP transport URL.',
},
headers: {
type: 'object',
description: 'Additional HTTP headers sent to the server.',
additionalProperties: { type: 'string' },
},
tcp: {
type: 'string',
description: 'TCP address for websocket transport.',
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds for MCP requests.',
},
trust: {
type: 'boolean',
description:
'Marks the server as trusted. Trusted servers may gain additional capabilities.',
},
description: {
type: 'string',
description: 'Human-readable description of the server.',
},
includeTools: {
type: 'array',
description:
'Subset of tools that should be enabled for this server. When omitted all tools are enabled.',
items: { type: 'string' },
},
excludeTools: {
type: 'array',
description:
'Tools that should be disabled for this server even if exposed.',
items: { type: 'string' },
},
extension: {
type: 'object',
description:
'Metadata describing the Gemini CLI extension that owns this MCP server.',
additionalProperties: { type: ['string', 'boolean', 'number'] },
},
oauth: {
type: 'object',
description: 'OAuth configuration for authenticating with the server.',
additionalProperties: true,
},
authProviderType: {
type: 'string',
description:
'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).',
enum: [
'dynamic_discovery',
'google_credentials',
'service_account_impersonation',
],
},
targetAudience: {
type: 'string',
description:
'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).',
},
targetServiceAccount: {
type: 'string',
description:
'Service account email to impersonate (name@project.iam.gserviceaccount.com).',
},
},
},
TelemetrySettings: {
type: 'object',
description: 'Telemetry configuration for Gemini CLI.',
additionalProperties: false,
properties: {
enabled: {
type: 'boolean',
description: 'Enables telemetry emission.',
},
target: {
type: 'string',
description:
'Telemetry destination (for example `stderr`, `stdout`, or `otlp`).',
},
otlpEndpoint: {
type: 'string',
description: 'Endpoint for OTLP exporters.',
},
otlpProtocol: {
type: 'string',
description: 'Protocol for OTLP exporters.',
enum: ['grpc', 'http'],
},
logPrompts: {
type: 'boolean',
description: 'Whether prompts are logged in telemetry payloads.',
},
outfile: {
type: 'string',
description: 'File path for writing telemetry output.',
},
useCollector: {
type: 'boolean',
description: 'Whether to forward telemetry to an OTLP collector.',
},
},
},
BugCommandSettings: {
type: 'object',
description: 'Configuration for the bug report helper command.',
additionalProperties: false,
properties: {
urlTemplate: {
type: 'string',
description:
'Template used to open a bug report URL. Variables in the template are populated at runtime.',
},
},
required: ['urlTemplate'],
},
SummarizeToolOutputSettings: {
type: 'object',
description:
'Controls summarization behavior for individual tools. All properties are optional.',
additionalProperties: false,
properties: {
tokenBudget: {
type: 'number',
description:
'Maximum number of tokens used when summarizing tool output.',
},
},
},
CustomTheme: {
type: 'object',
description:
'Custom theme definition used for styling Gemini CLI output. Colors are provided as hex strings or named ANSI colors.',
additionalProperties: false,
properties: {
type: {
type: 'string',
enum: ['custom'],
default: 'custom',
},
name: {
type: 'string',
description: 'Theme display name.',
},
text: {
type: 'object',
additionalProperties: false,
properties: {
primary: { type: 'string' },
secondary: { type: 'string' },
link: { type: 'string' },
accent: { type: 'string' },
},
},
background: {
type: 'object',
additionalProperties: false,
properties: {
primary: { type: 'string' },
diff: {
type: 'object',
additionalProperties: false,
properties: {
added: { type: 'string' },
removed: { type: 'string' },
},
},
},
},
border: {
type: 'object',
additionalProperties: false,
properties: {
default: { type: 'string' },
focused: { type: 'string' },
},
},
ui: {
type: 'object',
additionalProperties: false,
properties: {
comment: { type: 'string' },
symbol: { type: 'string' },
gradient: {
type: 'array',
items: { type: 'string' },
},
},
},
status: {
type: 'object',
additionalProperties: false,
properties: {
error: { type: 'string' },
success: { type: 'string' },
warning: { type: 'string' },
},
},
Background: { type: 'string' },
Foreground: { type: 'string' },
LightBlue: { type: 'string' },
AccentBlue: { type: 'string' },
AccentPurple: { type: 'string' },
AccentCyan: { type: 'string' },
AccentGreen: { type: 'string' },
AccentYellow: { type: 'string' },
AccentRed: { type: 'string' },
DiffAdded: { type: 'string' },
DiffRemoved: { type: 'string' },
Comment: { type: 'string' },
Gray: { type: 'string' },
DarkGray: { type: 'string' },
GradientColors: {
type: 'array',
items: { type: 'string' },
},
},
required: ['type', 'name'],
},
StringOrStringArray: {
description: 'Accepts either a single string or an array of strings.',
anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
},
BooleanOrString: {
description: 'Accepts either a boolean flag or a string command name.',
anyOf: [{ type: 'boolean' }, { type: 'string' }],
},
};
export function getSettingsSchema(): SettingsSchemaType {
return SETTINGS_SCHEMA;
}

Some files were not shown because too many files have changed in this diff Show More