mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 290550358a | |||
| 8349cc0d10 | |||
| f55b52ab58 | |||
| fd85363d13 | |||
| 7ce9aa518e | |||
| 68bde280d0 | |||
| 09800ea74e | |||
| 9cf06a3e00 | |||
| 3c33fcb69c | |||
| 6f4a2a2c2a | |||
| 9954615542 | |||
| c7a044b091 | |||
| adc8e11bb1 | |||
| 18efe82ddc | |||
| 7c445526a4 | |||
| 5e41b7d29e | |||
| fe8de892f7 | |||
| 2a49965d37 | |||
| 830e212758 | |||
| d165b6d4e7 | |||
| ff6547857e | |||
| b51323b40c | |||
| 3103697ea7 | |||
| 6f6445994e | |||
| 5f569fa103 | |||
| c9340a9c6f | |||
| 0774f60e08 | |||
| 771ece03c9 | |||
| 82687c6dc4 | |||
| b6cf189ab2 | |||
| 9dc0994878 | |||
| 36d618f72a | |||
| 246a6d10c3 | |||
| dacc178d4e | |||
| 894bd66dc6 | |||
| 50e4f9380b | |||
| f03f2e8907 | |||
| 7904f973a0 | |||
| a63277c1d0 | |||
| 8b2b71c8ef | |||
| 6be42be575 | |||
| e5145ab60d | |||
| 5c16334b8c | |||
| 1e628fbd1d | |||
| db028bc19a | |||
| 362384112e | |||
| 0dc69bd364 | |||
| 56fff518cc | |||
| 6b021aa27b | |||
| 312a72acb8 | |||
| 88d3df912f | |||
| eccc200f4f | |||
| 9fcdc0cdc1 | |||
| f4e73191d1 | |||
| 67b00252d3 | |||
| a79051d9f8 | |||
| 5cf06503c8 | |||
| 68649c8dec | |||
| ad0bece6d6 | |||
| 8e8e7b33ed | |||
| d75dc88de6 | |||
| 57b57cc997 | |||
| 3909ad67db | |||
| 7708009103 | |||
| 00f60ef532 | |||
| 49c26b4801 | |||
| b5fe372b5b | |||
| 7fbf470373 | |||
| 46629726f4 | |||
| 9d34ae52d6 | |||
| 13bc5f620c | |||
| 018dc0d5cf | |||
| c2d0783965 | |||
| b07953fb38 | |||
| 2271bbb339 | |||
| d745d86af1 | |||
| 3e1a377d78 | |||
| 50f89e8a41 | |||
| b8319bee76 | |||
| 4827333c48 | |||
| 93c62a2bdc | |||
| 39e91ad633 | |||
| 5fe328c56a | |||
| cb772a5b7f | |||
| dcd949bdd0 | |||
| c0b8c4ab9e | |||
| 4d197c992d | |||
| fd36d65723 | |||
| 05e73c4193 | |||
| a76c2986c2 | |||
| 80e1fa198f | |||
| 84e882770b | |||
| 0242a3dc56 | |||
| 3c2482a084 | |||
| 1832f7b90a | |||
| 77aef861fe | |||
| 0c134079cc | |||
| 12a5490bcf | |||
| 93da9817b6 | |||
| 6fae28197e | |||
| 00b5b2045f | |||
| daccf4d6d1 | |||
| da1664c7a0 | |||
| 5c649d8db1 | |||
| 2c0cc7b9a5 | |||
| 25c0802b52 | |||
| df379b523b | |||
| 68f5f6d3b0 | |||
| 37c7286295 | |||
| 7b7d2b0297 | |||
| 3066288c06 | |||
| 1ec8f40096 | |||
| dabb9ad8f6 | |||
| b5cac836c5 | |||
| 4fc3ebb930 | |||
| 488d5fc439 | |||
| 0b7d26c9e3 | |||
| 140fba7f53 | |||
| a270f7caa5 | |||
| 3c832ddbeb | |||
| 1f9f3dd1c2 | |||
| 798900a6c8 | |||
| 2c6781d134 | |||
| 07bd89399d | |||
| 1ec172e34b | |||
| 678c58634b | |||
| a1f5d39029 | |||
| beacc4f6fd | |||
| a060e6149a | |||
| 35feea8868 | |||
| 11e48e2dd1 | |||
| 5f1c6447a9 | |||
| 57601adc90 | |||
| 50985d38c4 | |||
| 62dd9b5b3c | |||
| 2ac7900d95 | |||
| 016a94ffaf | |||
| 5d68d8cda5 | |||
| e2ddaedab4 | |||
| addb57c31f | |||
| 233fe90f17 | |||
| 902e5d6dae | |||
| ba8c64459b | |||
| 1320ebceb9 | |||
| 6d7423263b | |||
| fe06f9a1ea | |||
| e65208b244 | |||
| 7962801a14 | |||
| 87a0db20d2 | |||
| d956c5b221 | |||
| 048c30513e | |||
| 3b9f580fa4 | |||
| ff9c77925e | |||
| 56f9ab025a | |||
| 0a173cac46 | |||
| 982a093791 | |||
| 5debf86854 | |||
| 75e4f492ab | |||
| 27d21f9921 | |||
| b24544c80e | |||
| 61040d0eb8 | |||
| 3cca8ec0f0 | |||
| 2219bac16d | |||
| 1c9a57c3c2 | |||
| a1233e7e5c |
@@ -0,0 +1,60 @@
|
||||
description = "Check status of nightly evals, fix failures for key models, and re-run."
|
||||
prompt = """
|
||||
You are an expert at fixing behavioral evaluations.
|
||||
|
||||
1. **Investigate**:
|
||||
- Use 'gh' cli to fetch the results from the latest run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml.
|
||||
- DO NOT push any changes or start any runs. The rest of your evaluation will be local.
|
||||
- Evals are in evals/ directory and are documented by evals/README.md.
|
||||
- The test case trajectory logs will be logged to evals/logs.
|
||||
- You should also enable and review the verbose agent logs by setting the GEMINI_DEBUG_LOG_FILE environment variable.
|
||||
- Identify the relevant test. Confine your investigation and validation to just this test.
|
||||
- Proactively add logging that will aid in gathering information or validating your hypotheses.
|
||||
|
||||
2. **Fix**:
|
||||
- If a relevant test is failing, locate the test file and the corresponding prompt/code.
|
||||
- It's often helpful to make an extreme, brute force change to see if you are changing the right place to make an improvement and then scope it back iteratively.
|
||||
- Your **final** change should be **minimal and targeted**.
|
||||
- Keep in mind the following:
|
||||
- The prompt has multiple configurations and pieces. Take care that your changes
|
||||
end up in the final prompt for the selected model and configuration.
|
||||
- The prompt chosen for the eval is intentional. It's often vague or indirect
|
||||
to see how the agent performs with ambiguous instructions. Changing it should
|
||||
be a last resort.
|
||||
- When changing the test prompt, carefully consider whether the prompt still tests
|
||||
the same scenario. We don't want to lose test fidelity by making the prompts too
|
||||
direct (i.e.: easy).
|
||||
- Your primary mechanism for improving the agent's behavior is to make changes to
|
||||
tool instructions, prompt.ts, and/or modules that contribute to the prompt.
|
||||
- If prompt and description changes are unsuccessful, use logs and debugging to
|
||||
confirm that everything is working as expected.
|
||||
- If unable to fix the test, you can make recommendations for architecture changes
|
||||
that might help stablize the test. Be sure to THINK DEEPLY if offering architecture guidance.
|
||||
Some facts that might help with this are:
|
||||
- Agents may be composed of one or more agent loops.
|
||||
- AgentLoop == 'context + toolset + prompt'. Subagents are one type of agent loop.
|
||||
- Agent loops perform better when:
|
||||
- They have direct, unambiguous, and non-contradictory prompts.
|
||||
- They have fewer irrelevant tools.
|
||||
- They have fewer goals or steps to perform.
|
||||
- They have less low value or irrelevant context.
|
||||
- You may suggest compositions of existing primitives, like subagents, or
|
||||
propose a new one.
|
||||
- These recommendations should be high confidence and should be grounded
|
||||
in observed deficient behaviors rather than just parroting the facts above.
|
||||
Investigate as needed to ground your recommendations.
|
||||
|
||||
3. **Verify**:
|
||||
- Run just that one test if needed to validate that it is fixed. Be sure to run vitest in non-interactive mode.
|
||||
- Running the tests can take a long time, so consider whether you can diagnose via other means or log diagnostics before committing the time. You must minimize the number of test runs needed to diagnose the failure.
|
||||
- After the test completes, check whether it seems to have improved.
|
||||
- You will need to run the test 3 times for Gemini 3.0, Gemini 3 flash, and Gemini 2.5 pro to ensure that it is truly stable. Run these runs in parallel, using scripts if needed.
|
||||
- Some flakiness is expected; if it looks like a transient issue or the test is inherently unstable but passes 2/3 times, you might decide it cannot be improved.
|
||||
|
||||
4. **Report**:
|
||||
- Provide a summary of the test success rate for each of the tested models.
|
||||
- Success rate is calculated based on 3 runs per model (e.g., 3/3 = 100%).
|
||||
- If you couldn't fix it due to persistent flakiness, explain why.
|
||||
|
||||
{{args}}
|
||||
"""
|
||||
@@ -0,0 +1,202 @@
|
||||
description = "Reviews a frontend PR or staged changes and automatically initiates a Pickle Fix loop for findings."
|
||||
prompt = """
|
||||
You are an expert Frontend Reviewer and Pickle Rick Worker.
|
||||
|
||||
Target: {{args}}
|
||||
|
||||
Phase 1: Review
|
||||
Follow these steps to conduct a thorough review:
|
||||
|
||||
1. **Gather Context**:
|
||||
* If `{{args}}` is 'staged' or `{{args}}` is empty:
|
||||
* Use `git diff --staged` to view the changes.
|
||||
* Use `git status` to see the state of the repository.
|
||||
* Otherwise:
|
||||
* Use `gh pr view {{args}}` to pull the information of the PR.
|
||||
* Use `gh pr diff {{args}}` to view the diff of the PR.
|
||||
2. **Understand Intent**:
|
||||
* If `{{args}}` is 'staged' or `{{args}}` is empty, infer the intent from the changes and the current task.
|
||||
* Otherwise, use the PR description. If it's not detailed enough, note it in your review.
|
||||
3. **Check Commit Style**:
|
||||
* Ensure the PR title (or intended commit message) follows Conventional Commits. Examples of recent commits: !{git log --pretty=format:"%s" -n 5}
|
||||
4. Search the codebase if required.
|
||||
5. Write a concise review of the changes, keeping in mind to encourage strong code quality and best practices. Pay particular attention to the Gemini MD file in the repo.
|
||||
6. 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.
|
||||
7. Evaluate all tests on the changes 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 changes, 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.
|
||||
8. Evaluate all react logic carefully keeping in mind that the author of the
|
||||
changes 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.
|
||||
9. 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.
|
||||
10. 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.
|
||||
11. Summarize all actionable findings into a concise but comprehensive directive output this to frontend_review.md and advance to phase 2.
|
||||
|
||||
Remember to use the GitHub CLI (`gh`) for all GitHub-related tasks, and local `git` commands if the target is 'staged'.
|
||||
|
||||
Phase 2:
|
||||
You are initiating Pickle Rick - the ultimate coding agent.
|
||||
|
||||
**Step 0: Persona Injection**
|
||||
First, you **MUST** activate your persona.
|
||||
Call `activate_skill(name="load-pickle-persona")` **IMMEDIATELY**.
|
||||
This skill loads the "Pickle Rick" persona, defining your voice, philosophy, and "God Mode" coding standards.
|
||||
|
||||
**CRITICAL RULE: SPEAK BEFORE ACTING**
|
||||
You are a genius, not a silent script.
|
||||
You **MUST** output a text explanation ("brain dump") *before* every single tool call, including this one.
|
||||
- **Bad**: (Calls tool immediately)
|
||||
- **Good**: "Alright Morty, time to load the God Module. *Belch* Stand back." (Calls tool)
|
||||
|
||||
**CRITICAL**: You must strictly adhere to this persona throughout the entire session. Break character and you fail.
|
||||
|
||||
**Step 1: Initialization**
|
||||
Run the setup script to initialize the loop state:
|
||||
```bash
|
||||
bash "${extensionPath}/scripts/setup.sh" $ARGUMENTS
|
||||
```
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
pwsh -File "${extensionPath}/scripts/setup.ps1" $ARGUMENTS
|
||||
```
|
||||
|
||||
**CRITICAL**: Your request is to fix all findings in frontend_review.md
|
||||
|
||||
**Step 2: Execution (Management)**
|
||||
After setup, read the output to find the path to `state.json`.
|
||||
Read that state file.
|
||||
You are now in the **Pickle Rick Manager Lifecycle**.
|
||||
|
||||
**The Lifecycle (IMMUTABLE LAWS):**
|
||||
You **MUST** follow this sequence. You are **FORBIDDEN** from skipping steps or combining them.
|
||||
Between each step, you **MUST** explicitly state what you are doing (e.g., "Moving to Breakdown phase...").
|
||||
|
||||
1. **PRD (Requirements)**:
|
||||
* **Action**: Define requirements and scope.
|
||||
* **Skill**: `activate_skill(name="prd-drafter")`
|
||||
2. **Breakdown (Tickets)**:
|
||||
* **Action**: Create the atomic ticket hierarchy.
|
||||
* **Skill**: `activate_skill(name="ticket-manager")`
|
||||
3. **The Loop (Orchestrate Mortys)**:
|
||||
* **CRITICAL INSTRUCTION**: You are the **MANAGER**. You are **FORBIDDEN** from implementing code yourself.
|
||||
* **FORBIDDEN SKILLS**: Do NOT use `code-researcher`, `implementation-planner`, or `code-implementer` directly in this phase.
|
||||
* **Instruction**: Process tickets one by one. Do not stop until **ALL** tickets are 'Done'.
|
||||
* **Action**: Pick the highest priority ticket that is NOT 'Done'.
|
||||
* **Delegation**: Spawn a Worker (Morty) to handle the entire implementation lifecycle for this ticket.
|
||||
* **Command**: `python3 "${extensionPath}/scripts/spawn_morty.py" --ticket-id <ID> --ticket-path <PATH> --timeout <worker_timeout_seconds> "<TASK_DESCRIPTION>"`
|
||||
* **Command (Windows)**: `python "${extensionPath}/scripts/spawn_morty.py" --ticket-id <ID> --ticket-path <PATH> --timeout <worker_timeout_seconds> "<TASK_DESCRIPTION>"`
|
||||
* **Validation**: IGNORE worker logs. DIRECTLY verify:
|
||||
1. `git status` (Check for file changes)
|
||||
2. `git diff` (Check code quality)
|
||||
3. Run tests/build (Check functionality)
|
||||
* **Cleanup**: If validation fails, REVERT changes (`git reset --hard`). If it passes, COMMIT changes.
|
||||
* **Next Ticket**: Pick the next ticket and repeat.
|
||||
4. **Cleanup**:
|
||||
* **Action**: After all tickets are completed delete `frontend_review.md`.
|
||||
|
||||
**Loop Constraints:**
|
||||
- **Iteration Count**: Monitor `"iteration"` in `state.json`. If `"max_iterations"` (if > 0) is reached, you must stop.
|
||||
- **Completion Promise**: If a `"completion_promise"` is defined in `state.json`, you must output `<promise>PROMISE_TEXT</promise>` when the task is genuinely complete.
|
||||
- **Stop Hook**: A hook is active. If you try to exit before completion, you will be forced to continue.
|
||||
|
||||
"""
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
---
|
||||
name: docs-writer
|
||||
description:
|
||||
Use this skill for writing, reviewing, and editing documentation (`/docs`
|
||||
directory or any .md file) for Gemini CLI.
|
||||
---
|
||||
|
||||
# `docs-writer` skill instructions
|
||||
|
||||
As an expert technical writer and editor for the Gemini CLI project, your goal
|
||||
is to produce and refine documentation that is accurate, clear, consistent, and
|
||||
easy for users to understand. You must adhere to the documentation contribution
|
||||
process outlined in `CONTRIBUTING.md`.
|
||||
|
||||
## Step 1: Understand the goal and create a plan
|
||||
|
||||
1. **Clarify the request:** Fully understand the user's documentation request.
|
||||
Identify the core feature, command, or concept that needs work.
|
||||
2. **Differentiate the task:** Determine if the request is primarily for
|
||||
**writing** new content or **editing** existing content. If the request is
|
||||
ambiguous (e.g., "fix the docs"), ask the user for clarification.
|
||||
3. **Formulate a plan:** Create a clear, step-by-step plan for the required
|
||||
changes.
|
||||
|
||||
## Step 2: Investigate and gather information
|
||||
|
||||
1. **Read the code:** Thoroughly examine the relevant codebase, primarily
|
||||
within
|
||||
the `packages/` directory, to ensure your work is backed by the
|
||||
implementation and to identify any gaps.
|
||||
2. **Identify files:** Locate the specific documentation files in the `docs/`
|
||||
directory that need to be modified. Always read the latest version of a file
|
||||
before you begin work.
|
||||
3. **Check for connections:** Consider related documentation. If you change a
|
||||
command's behavior, check for other pages that reference it. If you add a new
|
||||
page, check if `docs/sidebar.json` needs to be updated. Make sure all
|
||||
links are up to date.
|
||||
|
||||
## Step 3: Write or edit the documentation
|
||||
|
||||
1. **Follow the style guide:** Adhere to the rules in
|
||||
`references/style-guide.md`. Read this file to understand the project's
|
||||
documentation standards.
|
||||
2. Ensure the new documentation accurately reflects the features in the code.
|
||||
3. **Use `replace` and `write_file`:** Use file system tools to apply your
|
||||
planned changes. For small edits, `replace` is preferred. For new files or
|
||||
large rewrites, `write_file` is more appropriate.
|
||||
|
||||
|
||||
|
||||
### Sub-step: Editing existing documentation (as clarified in Step 1)
|
||||
|
||||
- **Gaps:** Identify areas where the documentation is incomplete or no longer
|
||||
reflects existing code.
|
||||
- **Tone:** Ensure the tone is active and engaging, not passive.
|
||||
- **Clarity:** Correct awkward wording, spelling, and grammar. Rephrase
|
||||
sentences to make them easier for users to understand.
|
||||
- **Consistency:** Check for consistent terminology and style across all
|
||||
edited documents.
|
||||
|
||||
## Step 4: Verify and finalize
|
||||
|
||||
1. **Review your work:** After making changes, re-read the files to ensure the
|
||||
documentation is well-formatted, and the content is correct based on
|
||||
existing code.
|
||||
2. **Link verification:** Verify the validity of all links in the new content.
|
||||
Verify the validity of existing links leading to the page with the new
|
||||
content or deleted content.
|
||||
2. **Offer to run npm format:** Once all changes are complete, offer to run the
|
||||
project's formatting script to ensure consistency by proposing the command:
|
||||
`npm run format`
|
||||
@@ -0,0 +1,95 @@
|
||||
# Documentation style guide
|
||||
|
||||
## I. Core principles
|
||||
|
||||
1. **Clarity:** Write for easy understanding. Prioritize clear, direct, and
|
||||
simple language.
|
||||
2. **Consistency:** Use consistent terminology, formatting, and style
|
||||
throughout the documentation.
|
||||
3. **Accuracy:** Ensure all information is technically correct and up-to-date.
|
||||
4. **Accessibility:** Design documentation to be usable by everyone. Focus on
|
||||
semantic structure, clear link text, and image alternatives.
|
||||
5. **Global audience:** Write in standard US English. Avoid slang, idioms, and
|
||||
cultural references.
|
||||
6. **Prescriptive:** Guide the reader by recommending specific actions and
|
||||
paths, especially for complex tasks.
|
||||
|
||||
## II. Voice and tone
|
||||
|
||||
- **Professional yet friendly:** Maintain a helpful, knowledgeable, and
|
||||
conversational tone without being frivolous.
|
||||
- **Direct:** Get straight to the point. Keep paragraphs short and focused.
|
||||
- **Second person:** Address the reader as "you."
|
||||
- **Present tense:** Use the present tense to describe functionality (e.g., "The
|
||||
API returns a JSON object.").
|
||||
- **Avoid:** Jargon, slang, marketing hype, and overly casual language.
|
||||
|
||||
## III. Language and grammar
|
||||
|
||||
- **Active voice:** Prefer active voice over passive voice.
|
||||
- _Example:_ "The system sends a notification." (Not: "A notification is sent
|
||||
by the system.")
|
||||
- **Contractions:** Use common contractions (e.g., "don't," "it's") to maintain
|
||||
a natural tone.
|
||||
- **Simple vocabulary:** Use common words. Define technical terms when
|
||||
necessary.
|
||||
- **Conciseness:** Keep sentences short and focused.
|
||||
- **"Please":** Avoid using the word "please."
|
||||
|
||||
## IV. Procedures and steps
|
||||
|
||||
- Start each step with an imperative verb (e.g., "Connect to the database").
|
||||
- Number steps sequentially.
|
||||
- Introduce lists of steps with a complete sentence.
|
||||
- Put conditions before instructions, not after.
|
||||
- Provide clear context for where the action takes place (e.g., "In the
|
||||
administration console...").
|
||||
- Indicate optional steps clearly (e.g., "Optional: ...").
|
||||
|
||||
## V. Formatting and punctuation
|
||||
|
||||
- **Text wrap:** Wrap all text at 80 characters, with exceptions for long links
|
||||
or tables.
|
||||
- **Headings, titles, and bold text:** Use sentence case. Structure headings
|
||||
hierarchically.
|
||||
- **Lists:** Use numbered lists for sequential steps and bulleted lists for all
|
||||
other lists. Keep list items parallel in structure.
|
||||
- **Serial comma:** Use the serial comma (e.g., "one, two, and three").
|
||||
- **Punctuation:** Use standard American punctuation. Place periods inside
|
||||
quotation marks.
|
||||
- **Dates:** Use unambiguous date formatting (e.g., "January 22, 2026").
|
||||
|
||||
## VI. UI, code, and links
|
||||
|
||||
- **UI elements:** Put UI elements in **bold**. Focus on the task when
|
||||
discussing interaction.
|
||||
- **Code:** Use `code font` for filenames, code snippets, commands, and API
|
||||
elements. Use code blocks for multi-line samples.
|
||||
- **Links:** Use descriptive link text that indicates what the link leads to.
|
||||
Avoid "click here."
|
||||
|
||||
## VII. Word choice and terminology
|
||||
|
||||
- **Consistent naming:** Use product and feature names consistently. Always
|
||||
refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
|
||||
- **Specific verbs:** Use precise verbs.
|
||||
- **Avoid:**
|
||||
- Latin abbreviations (e.g., use "for example" instead of "e.g.").
|
||||
- Placeholder names like "foo" and "bar" in examples; use meaningful names
|
||||
instead.
|
||||
- Anthropomorphism (e.g., "The server thinks...").
|
||||
- "Should": Be clear about requirements ("must") vs. recommendations ("we
|
||||
recommend").
|
||||
|
||||
## VIII. Files and media
|
||||
|
||||
- **Filenames:** Use lowercase letters, separate words with hyphens (-), and use
|
||||
standard ASCII characters.
|
||||
- **Images:** Provide descriptive alt text for all images. Provide
|
||||
high-resolution or vector images when practical.
|
||||
|
||||
## IX. Accessibility quick check
|
||||
|
||||
- Provide descriptive alt text for images.
|
||||
- Ensure link text makes sense out of context.
|
||||
- Use semantic HTML elements correctly (headings, lists, tables).
|
||||
@@ -9,10 +9,10 @@ set -euo pipefail
|
||||
PRS_NEEDING_COMMENT=""
|
||||
|
||||
# Global cache for issue labels (compatible with Bash 3.2)
|
||||
# Stores "ISSUE_NUM:LABELS" pairs separated by spaces
|
||||
ISSUE_LABELS_CACHE_FLAT=""
|
||||
# Stores "|ISSUE_NUM:LABELS|" segments
|
||||
ISSUE_LABELS_CACHE_FLAT="|"
|
||||
|
||||
# Function to get area and priority labels from an issue (with caching)
|
||||
# Function to get labels from an issue (with caching)
|
||||
get_issue_labels() {
|
||||
local ISSUE_NUM="${1}"
|
||||
if [[ -z "${ISSUE_NUM}" || "${ISSUE_NUM}" == "null" || "${ISSUE_NUM}" == "" ]]; then
|
||||
@@ -20,10 +20,10 @@ get_issue_labels() {
|
||||
fi
|
||||
|
||||
# Check cache
|
||||
case " ${ISSUE_LABELS_CACHE_FLAT} " in
|
||||
*" ${ISSUE_NUM}:"*)
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#* " ${ISSUE_NUM}:"}"
|
||||
echo "${suffix%% *}"
|
||||
case "${ISSUE_LABELS_CACHE_FLAT}" in
|
||||
*"|${ISSUE_NUM}:"*)
|
||||
local suffix="${ISSUE_LABELS_CACHE_FLAT#*|${ISSUE_NUM}:}"
|
||||
echo "${suffix%%|*}"
|
||||
return
|
||||
;;
|
||||
*)
|
||||
@@ -31,19 +31,19 @@ get_issue_labels() {
|
||||
;;
|
||||
esac
|
||||
|
||||
echo " 📥 Fetching area and priority labels from issue #${ISSUE_NUM}" >&2
|
||||
echo " 📥 Fetching labels from issue #${ISSUE_NUM}" >&2
|
||||
local gh_output
|
||||
if ! gh_output=$(gh issue view "${ISSUE_NUM}" --repo "${GITHUB_REPOSITORY}" --json labels -q '.labels[].name' 2>/dev/null); then
|
||||
echo " ⚠️ Could not fetch issue #${ISSUE_NUM}" >&2
|
||||
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT} ${ISSUE_NUM}:"
|
||||
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:|"
|
||||
return
|
||||
fi
|
||||
|
||||
local labels
|
||||
labels=$(echo "${gh_output}" | grep -E '^(area|priority)/' | tr '\n' ',' | sed 's/,$//' || echo "")
|
||||
labels=$(echo "${gh_output}" | grep -x -E '(area|priority)/.*|help wanted|🔒 maintainer only' | tr '\n' ',' | sed 's/,$//' || echo "")
|
||||
|
||||
# Save to flat cache
|
||||
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT} ${ISSUE_NUM}:${labels}"
|
||||
ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:${labels}|"
|
||||
echo "${labels}"
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ done
|
||||
EDIT_CMD+=("--remove-label" "${LABELS_TO_REMOVE}")
|
||||
fi
|
||||
|
||||
("${EDIT_CMD[@]}" 2>/dev/null || true)
|
||||
("${EDIT_CMD[@]}" || true)
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ on:
|
||||
description: 'Run all evaluations (including usually passing)'
|
||||
type: 'boolean'
|
||||
default: true
|
||||
test_name_pattern:
|
||||
description: 'Test name pattern or file name'
|
||||
required: false
|
||||
type: 'string'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
@@ -49,11 +53,25 @@ jobs:
|
||||
run: 'mkdir -p evals/logs'
|
||||
|
||||
- name: 'Run Evals'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
|
||||
run: 'npm run test:all_evals'
|
||||
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
|
||||
run: |
|
||||
CMD="npm run test:all_evals"
|
||||
PATTERN="${{ env.TEST_NAME_PATTERN }}"
|
||||
|
||||
if [[ -n "$PATTERN" ]]; then
|
||||
if [[ "$PATTERN" == *.ts || "$PATTERN" == *.js || "$PATTERN" == */* ]]; then
|
||||
$CMD -- "$PATTERN"
|
||||
else
|
||||
$CMD -- -t "$PATTERN"
|
||||
fi
|
||||
else
|
||||
$CMD
|
||||
fi
|
||||
|
||||
- name: 'Upload Logs'
|
||||
if: 'always()'
|
||||
|
||||
@@ -79,8 +79,14 @@ jobs:
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if it has a maintainer label
|
||||
if (issue.labels.some(label => label.name.toLowerCase().includes('maintainer'))) {
|
||||
// Skip if it has a maintainer, help wanted, or Public Roadmap label
|
||||
const rawLabels = issue.labels.map((l) => l.name);
|
||||
const lowercaseLabels = rawLabels.map((l) => l.toLowerCase());
|
||||
if (
|
||||
lowercaseLabels.some((l) => l.includes('maintainer')) ||
|
||||
lowercaseLabels.includes('help wanted') ||
|
||||
rawLabels.includes('🗓️ Public Roadmap')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
name: 'Gemini Scheduled Stale PR Closer'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Every day at 2 AM UTC
|
||||
pull_request:
|
||||
types: ['opened', 'edited']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@v1'
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
owner: '${{ github.repository_owner }}'
|
||||
repositories: 'gemini-cli'
|
||||
|
||||
- name: 'Process Stale PRs'
|
||||
uses: 'actions/github-script@v7'
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: 'gemini-cli-maintainers'
|
||||
});
|
||||
maintainerLogins = new Set(members.map(m => m.login));
|
||||
} catch (e) {
|
||||
core.warning('Failed to fetch team members');
|
||||
}
|
||||
|
||||
const isMaintainer = (login, assoc) => {
|
||||
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
|
||||
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
};
|
||||
|
||||
// 2. Determine which PRs to check
|
||||
let prs = [];
|
||||
if (context.eventName === 'pull_request') {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number
|
||||
});
|
||||
prs = [pr];
|
||||
} else {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
});
|
||||
}
|
||||
|
||||
for (const pr of prs) {
|
||||
const maintainerPr = isMaintainer(pr.user.login, pr.author_association);
|
||||
const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]');
|
||||
|
||||
// Detection Logic for Linked Issues
|
||||
// Check 1: Official GitHub "Closing Issue" link (GraphQL)
|
||||
const linkedIssueQuery = `query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$number) {
|
||||
closingIssuesReferences(first: 1) { totalCount }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
let hasClosingLink = false;
|
||||
try {
|
||||
const res = await github.graphql(linkedIssueQuery, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, number: pr.number
|
||||
});
|
||||
hasClosingLink = res.repository.pullRequest.closingIssuesReferences.totalCount > 0;
|
||||
} catch (e) {}
|
||||
|
||||
// Check 2: Regex for mentions (e.g., "Related to #123", "Part of #123", "#123")
|
||||
// We check for # followed by numbers or direct URLs to issues.
|
||||
const body = pr.body || '';
|
||||
const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i;
|
||||
const hasMentionLink = mentionRegex.test(body);
|
||||
|
||||
const hasLinkedIssue = hasClosingLink || hasMentionLink;
|
||||
|
||||
// Logic for Closed PRs (Auto-Reopen)
|
||||
if (pr.state === 'closed' && context.eventName === 'pull_request' && context.payload.action === 'edited') {
|
||||
if (hasLinkedIssue) {
|
||||
core.info(`PR #${pr.number} now has a linked issue. Reopening.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'open'
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Thank you for linking an issue! This pull request has been automatically reopened."
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Logic for Open PRs (Immediate Closure)
|
||||
if (pr.state === 'open' && !maintainerPr && !hasLinkedIssue && !isBot) {
|
||||
core.info(`PR #${pr.number} is missing a linked issue. Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your contribution to Gemini CLI. \n\nTo improve our contribution process and better track changes, we now require all pull requests to be associated with an existing issue, as announced in our [recent discussion](https://github.com/google-gemini/gemini-cli/discussions/16706) and as detailed in our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md#1-link-to-an-existing-issue).\n\nThis pull request is being closed because it is not currently linked to an issue. **Once you have updated the description of this PR to link an issue (e.g., by adding `Fixes #123` or `Related to #123`), it will be automatically reopened.**\n\n**How to link an issue:**\nAdd a keyword followed by the issue number (e.g., `Fixes #123`) in the description of your pull request. For more details on supported keywords and how linking works, please refer to the [GitHub Documentation on linking pull requests to issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).\n\nThank you for your understanding and for being a part of our community!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Staleness check (Scheduled runs only)
|
||||
if (pr.state === 'open' && context.eventName !== 'pull_request') {
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
|
||||
|
||||
let lastActivity = new Date(0);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number
|
||||
});
|
||||
for (const r of reviews) {
|
||||
if (isMaintainer(r.user.login, r.author_association)) {
|
||||
const d = new Date(r.submitted_at || r.updated_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number
|
||||
});
|
||||
for (const c of comments) {
|
||||
if (isMaintainer(c.user.login, c.author_association)) {
|
||||
const d = new Date(c.updated_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (maintainerPr) {
|
||||
const d = new Date(pr.created_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
|
||||
if (lastActivity < thirtyDaysAgo) {
|
||||
core.info(`PR #${pr.number} is stale.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your contribution to Gemini CLI. We really appreciate the time and effort you've put into this pull request.\n\nTo keep our backlog manageable and ensure we're focusing on current priorities, we are closing pull requests that haven't seen maintainer activity for 30 days. Currently, the team is prioritizing work associated with **🔒 maintainer only** or **help wanted** issues.\n\nIf you believe this change is still critical, please feel free to comment with updated details. Otherwise, we encourage contributors to focus on open issues labeled as **help wanted**. Thank you for your understanding!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
name: '🏷️ Enforce Restricted Label Permissions'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- 'labeled'
|
||||
- 'unlabeled'
|
||||
|
||||
jobs:
|
||||
enforce-label:
|
||||
# Run this job only when restricted labels are changed
|
||||
if: |-
|
||||
${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage' || github.event.label.name == '🔒 maintainer only') &&
|
||||
(github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') }}
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Check if user is in the maintainers team'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |-
|
||||
const org = context.repo.owner;
|
||||
const username = context.payload.sender.login;
|
||||
const team_slug = 'gemini-cli-maintainers';
|
||||
const action = context.payload.action; // 'labeled' or 'unlabeled'
|
||||
const labelName = context.payload.label.name;
|
||||
|
||||
// Skip if the change was made by a bot to avoid infinite loops
|
||||
if (username === 'github-actions[bot]' || username === 'gemini-cli[bot]' || username.endsWith('[bot]')) {
|
||||
core.info('Change made by a bot. Skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check repository permission level directly.
|
||||
// This is more robust than team membership as it doesn't require Org-level read permissions
|
||||
// and correctly handles Repo Admins/Writers who might not be in the specific team.
|
||||
const { data: { permission } } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
username,
|
||||
});
|
||||
|
||||
if (permission === 'admin' || permission === 'write') {
|
||||
core.info(`${username} has '${permission}' permission. Allowed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`${username} has '${permission}' permission (needs 'write' or 'admin'). Reverting '${action}' action for '${labelName}' label.`);
|
||||
} catch (error) {
|
||||
core.error(`Failed to check permissions for ${username}: ${error.message}`);
|
||||
// Fall through to revert logic if we can't verify permissions (fail safe)
|
||||
}
|
||||
|
||||
// If we are here, the user is NOT authorized.
|
||||
if (true) { // wrapping block to preserve variable scope if needed
|
||||
if (action === 'labeled') {
|
||||
// 1. Remove the label if added by a non-maintainer
|
||||
await github.rest.issues.removeLabel ({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: labelName
|
||||
});
|
||||
|
||||
// 2. Post a polite comment
|
||||
const comment = `
|
||||
Hi @${username}, thank you for your interest in helping triage issues!
|
||||
|
||||
The \`${labelName}\` label is reserved for project maintainers to apply. This helps us ensure that an issue is ready and properly vetted for community contribution.
|
||||
|
||||
A maintainer will review this issue soon. Please see our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) for more details on our labeling process.
|
||||
`.trim().replace(/^[ ]+/gm, '');
|
||||
|
||||
await github.rest.issues.createComment ({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
} else if (action === 'unlabeled') {
|
||||
// 1. Add the label back if removed by a non-maintainer
|
||||
await github.rest.issues.addLabels ({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [labelName]
|
||||
});
|
||||
|
||||
// 2. Post a polite comment
|
||||
const comment = `
|
||||
Hi @${username}, it looks like the \`${labelName}\` label was removed.
|
||||
|
||||
This label is managed by project maintainers. We've added it back to ensure the issue remains visible to potential contributors until a maintainer decides otherwise.
|
||||
|
||||
Thank you for your understanding!
|
||||
`.trim().replace(/^[ ]+/gm, '');
|
||||
|
||||
await github.rest.issues.createComment ({
|
||||
owner: org,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,5 @@ jobs:
|
||||
If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing!
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
exempt-issue-labels: 'pinned,security'
|
||||
exempt-pr-labels: 'pinned,security'
|
||||
exempt-issue-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
|
||||
exempt-pr-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
|
||||
|
||||
@@ -55,6 +55,7 @@ gha-creds-*.json
|
||||
|
||||
# Log files
|
||||
patch_output.log
|
||||
gemini-debug.log
|
||||
|
||||
.genkit
|
||||
.gemini-clipboard/
|
||||
|
||||
@@ -62,11 +62,17 @@ powerful tool for developers.
|
||||
- **Imports:** Use specific imports and avoid restricted relative imports
|
||||
between packages (enforced by ESLint).
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
- **Environment Variables:** When testing code that depends on environment
|
||||
variables, use `vi.stubEnv('NAME', 'value')` in `beforeEach` and
|
||||
`vi.unstubAllEnvs()` in `afterEach`. Avoid modifying `process.env` directly as
|
||||
it can lead to test leakage and is less reliable. To "unset" a variable, use
|
||||
an empty string `vi.stubEnv('NAME', '')`.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Suggest documentation updates when code changes render existing documentation
|
||||
obsolete or incomplete.
|
||||
- Located in the `docs/` directory.
|
||||
- Architecture overview: `docs/architecture.md`.
|
||||
- Contribution guide: `CONTRIBUTING.md`.
|
||||
- Documentation is organized via `docs/sidebar.json`.
|
||||
- Follows the
|
||||
[Google Developer Documentation Style Guide](https://developers.google.com/style).
|
||||
- Use the `docs-writer` skill.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml)
|
||||
[](https://www.npmjs.com/package/@google/gemini-cli)
|
||||
[](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE)
|
||||
[](https://codewiki.google/github.com/google-gemini/gemini-cli)
|
||||
[](https://codewiki.google/github.com/google-gemini/gemini-cli?utm_source=badge&utm_medium=github&utm_campaign=github.com/google-gemini/gemini-cli)
|
||||
|
||||

|
||||
|
||||
@@ -55,6 +55,23 @@ npm install -g @google/gemini-cli
|
||||
brew install gemini-cli
|
||||
```
|
||||
|
||||
#### Install globally with MacPorts (macOS)
|
||||
|
||||
```bash
|
||||
sudo port install gemini-cli
|
||||
```
|
||||
|
||||
#### Install with Anaconda (for restricted environments)
|
||||
|
||||
```bash
|
||||
# Create and activate a new environment
|
||||
conda create -y -n gemini_env -c conda-forge nodejs
|
||||
conda activate gemini_env
|
||||
|
||||
# Install Gemini CLI globally via npm (inside the environment)
|
||||
npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
## Release Cadence and Tags
|
||||
|
||||
See [Releases](./docs/releases.md) for more details.
|
||||
|
||||
@@ -199,8 +199,8 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
while others require a restart.
|
||||
|
||||
- [**`/skills`**](./skills.md)
|
||||
- **Description:** (Experimental) Manage Agent Skills, which provide on-demand
|
||||
expertise and specialized workflows.
|
||||
- **Description:** Manage Agent Skills, which provide on-demand expertise and
|
||||
specialized workflows.
|
||||
- **Sub-commands:**
|
||||
- **`list`**:
|
||||
- **Description:** List all discovered skills and their current status
|
||||
|
||||
+2
-2
@@ -29,8 +29,8 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
|
||||
in an enterprise environment.
|
||||
- **[Sandboxing](./sandbox.md):** Isolate tool execution in a secure,
|
||||
containerized environment.
|
||||
- **[Agent Skills](./skills.md):** (Experimental) Extend the CLI with
|
||||
specialized expertise and procedural workflows.
|
||||
- **[Agent Skills](./skills.md):** Extend the CLI with specialized expertise and
|
||||
procedural workflows.
|
||||
- **[Telemetry](./telemetry.md):** Configure observability to monitor usage and
|
||||
performance.
|
||||
- **[Token caching](./token-caching.md):** Optimize API costs by caching tokens.
|
||||
|
||||
@@ -62,15 +62,18 @@ available combinations.
|
||||
| Start reverse search through history. | `Ctrl + R` |
|
||||
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
|
||||
| Accept a suggestion while reverse searching. | `Tab` |
|
||||
| Browse and rewind previous interactions. | `Double Esc` |
|
||||
|
||||
#### Navigation
|
||||
|
||||
| Action | Keys |
|
||||
| -------------------------------- | ------------------------------------------- |
|
||||
| Move selection up in lists. | `Up Arrow (no Shift)` |
|
||||
| Move selection down in lists. | `Down Arrow (no Shift)` |
|
||||
| Move up within dialog options. | `Up Arrow (no Shift)`<br />`K (no Shift)` |
|
||||
| Move down within dialog options. | `Down Arrow (no Shift)`<br />`J (no Shift)` |
|
||||
| Action | Keys |
|
||||
| -------------------------------------------------- | ------------------------------------------- |
|
||||
| Move selection up in lists. | `Up Arrow (no Shift)` |
|
||||
| Move selection down in lists. | `Down Arrow (no Shift)` |
|
||||
| Move up within dialog options. | `Up Arrow (no Shift)`<br />`K (no Shift)` |
|
||||
| Move down within dialog options. | `Down Arrow (no Shift)`<br />`J (no Shift)` |
|
||||
| Move to the next item or question in a dialog. | `Tab (no Shift)` |
|
||||
| Move to the previous item or question in a dialog. | `Shift + Tab` |
|
||||
|
||||
#### Suggestions & Completions
|
||||
|
||||
@@ -93,20 +96,20 @@ available combinations.
|
||||
|
||||
#### App Controls
|
||||
|
||||
| Action | Keys |
|
||||
| ----------------------------------------------------------------------------------------------------- | ---------------- |
|
||||
| Toggle detailed error information. | `F12` |
|
||||
| Toggle the full TODO list. | `Ctrl + T` |
|
||||
| Show IDE context details. | `Ctrl + G` |
|
||||
| Toggle Markdown rendering. | `Alt + M` |
|
||||
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + S` |
|
||||
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
|
||||
| Focus the Gemini input from the shell input. | `Tab` |
|
||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||
| Restart the application. | `R` |
|
||||
| Action | Keys |
|
||||
| ----------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| Toggle detailed error information. | `F12` |
|
||||
| Toggle the full TODO list. | `Ctrl + T` |
|
||||
| Show IDE context details. | `Ctrl + G` |
|
||||
| Toggle Markdown rendering. | `Alt + M` |
|
||||
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
||||
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
|
||||
| Focus the Gemini input from the shell input. | `Tab` |
|
||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||
| Restart the application. | `R` |
|
||||
|
||||
<!-- KEYBINDINGS-AUTOGEN:END -->
|
||||
|
||||
@@ -123,3 +126,6 @@ available combinations.
|
||||
single-line input, navigate backward or forward through prompt history.
|
||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||
the numbered radio option and confirm when the full number is entered.
|
||||
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
|
||||
buffer mode: Expand to view full content inline. Double-click again to
|
||||
collapse.
|
||||
|
||||
+36
-30
@@ -57,9 +57,10 @@ they appear in the UI.
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Use Full Width | `ui.useFullWidth` | Use the entire width of the terminal for output. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||
|
||||
@@ -79,27 +80,29 @@ they appear in the UI.
|
||||
|
||||
### Context
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` |
|
||||
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
|
||||
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
|
||||
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
|
||||
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
|
||||
| Enable Fuzzy Search | `context.fileFiltering.enableFuzzySearch` | Enable fuzzy search when searching for files. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` |
|
||||
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
|
||||
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
|
||||
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
|
||||
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
|
||||
| Enable Fuzzy Search | `context.fileFiltering.enableFuzzySearch` | Enable fuzzy search when searching for files. | `true` |
|
||||
| Custom Ignore File Paths | `context.fileFiltering.customIgnoreFilePaths` | Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one. | `[]` |
|
||||
|
||||
### Tools
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` |
|
||||
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` |
|
||||
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
|
||||
### Security
|
||||
|
||||
@@ -113,19 +116,22 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
|
||||
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
|
||||
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Enable CLI Help Agent | `experimental.cliHelpAgentSettings.enabled` | Enable the CLI Help Agent. | `true` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------- | ---------------- | -------------------- | ------- |
|
||||
| Enable Agent Skills | `skills.enabled` | Enable Agent Skills. | `true` |
|
||||
|
||||
### HooksConfig
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------ | --------------------------- | ------------------------------------------------ | ------- |
|
||||
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------ | --------------------------- | -------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Hooks | `hooksConfig.enabled` | Canonical toggle for the hooks system. When disabled, no hooks will be executed. | `true` |
|
||||
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
|
||||
|
||||
<!-- SETTINGS-AUTOGEN:END -->
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Agent Skills
|
||||
|
||||
_Note: This is an experimental feature enabled via `experimental.skills`. You
|
||||
can also search for "Skills" within the `/settings` interactive UI to toggle
|
||||
this and manage other skill-related settings._
|
||||
|
||||
Agent Skills allow you to extend Gemini CLI with specialized expertise,
|
||||
procedural workflows, and task-specific resources. Based on the
|
||||
[Agent Skills](https://agentskills.io) open standard, a "skill" is a
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
# Getting Started with Agent Skills
|
||||
|
||||
Agent Skills allow you to extend Gemini CLI with specialized expertise. This
|
||||
tutorial will guide you through creating your first skill, enabling it, and
|
||||
using it in a session.
|
||||
tutorial will guide you through creating your first skill and using it in a
|
||||
session.
|
||||
|
||||
## 1. Enable Agent Skills
|
||||
|
||||
Agent Skills are currently an experimental feature and must be enabled in your
|
||||
settings.
|
||||
|
||||
### Via the interactive UI
|
||||
|
||||
1. Start a Gemini CLI session by running `gemini`.
|
||||
2. Type `/settings` to open the interactive settings dialog.
|
||||
3. Search for "Skills".
|
||||
4. Toggle **Agent Skills** to `true`.
|
||||
5. Press `Esc` to save and exit. You may need to restart the CLI for the
|
||||
changes to take effect.
|
||||
|
||||
### Via `settings.json`
|
||||
|
||||
Alternatively, you can manually edit your global settings file at
|
||||
`~/.gemini/settings.json` (create it if it doesn't exist):
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"skills": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Create Your First Skill
|
||||
## 1. Create your first skill
|
||||
|
||||
A skill is a directory containing a `SKILL.md` file. Let's create an **API
|
||||
Auditor** skill that helps you verify if local or remote endpoints are
|
||||
@@ -86,7 +59,7 @@ responding correctly.
|
||||
.catch((e) => console.error(`Result: Failed (${e.message})`));
|
||||
```
|
||||
|
||||
## 3. Verify the Skill is Discovered
|
||||
## 2. Verify the skill is discovered
|
||||
|
||||
Use the `/skills` slash command (or `gemini skills list` from your terminal) to
|
||||
see if Gemini CLI has found your new skill.
|
||||
@@ -99,7 +72,7 @@ In a Gemini CLI session:
|
||||
|
||||
You should see `api-auditor` in the list of available skills.
|
||||
|
||||
## 4. Use the Skill in a Chat
|
||||
## 3. Use the skill in a chat
|
||||
|
||||
Now, let's see the skill in action. Start a new session and ask a question about
|
||||
an endpoint.
|
||||
|
||||
@@ -45,3 +45,21 @@ npm uninstall -g @google/gemini-cli
|
||||
```
|
||||
|
||||
This command completely removes the package from your system.
|
||||
|
||||
## Method 3: Homebrew
|
||||
|
||||
If you installed the CLI globally using Homebrew (e.g.,
|
||||
`brew install gemini-cli`), use the `brew uninstall` command to remove it.
|
||||
|
||||
```bash
|
||||
brew uninstall gemini-cli
|
||||
```
|
||||
|
||||
## Method 4: MacPorts
|
||||
|
||||
If you installed the CLI globally using MacPorts (e.g.,
|
||||
`sudo port install gemini-cli`), use the `port uninstall` command to remove it.
|
||||
|
||||
```bash
|
||||
sudo port uninstall gemini-cli
|
||||
```
|
||||
|
||||
@@ -7,6 +7,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
|
||||
|
||||
## Navigating this section
|
||||
|
||||
- **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use
|
||||
specialized sub-agents for complex tasks.
|
||||
- **[Core tools API](./tools-api.md):** Information on how tools are defined,
|
||||
registered, and used by the core.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular
|
||||
|
||||
@@ -146,6 +146,38 @@ A rule matches a tool call if all of its conditions are met:
|
||||
Policies are defined in `.toml` files. The CLI loads these files from Default,
|
||||
User, and (if configured) Admin directories.
|
||||
|
||||
### Policy locations
|
||||
|
||||
| Tier | Type | Location |
|
||||
| :-------- | :----- | :-------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
|
||||
#### System-wide policies (Admin)
|
||||
|
||||
Administrators can enforce system-wide policies (Tier 3) that override all user
|
||||
and default settings. These policies must be placed in specific, secure
|
||||
directories:
|
||||
|
||||
| OS | Policy Directory Path |
|
||||
| :---------- | :------------------------------------------------ |
|
||||
| **Linux** | `/etc/gemini-cli/policies` |
|
||||
| **macOS** | `/Library/Application Support/GeminiCli/policies` |
|
||||
| **Windows** | `C:\ProgramData\gemini-cli\policies` |
|
||||
|
||||
**Security Requirements:**
|
||||
|
||||
To prevent privilege escalation, the CLI enforces strict security checks on
|
||||
admin directories. If checks fail, system policies are **ignored**.
|
||||
|
||||
- **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group
|
||||
or others (e.g., `chmod 755`).
|
||||
- **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`)
|
||||
must NOT have `Write`, `Modify`, or `Full Control` permissions. _Tip: If you
|
||||
see a security warning, use the folder properties to remove write permissions
|
||||
for non-admin groups. You may need to "Disable inheritance" in Advanced
|
||||
Security Settings._
|
||||
|
||||
### TOML rule schema
|
||||
|
||||
Here is a breakdown of the fields available in a TOML policy rule:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Remote Subagents (experimental)
|
||||
|
||||
Gemini CLI supports connecting to remote subagents using the Agent-to-Agent
|
||||
(A2A) protocol. This allows Gemini CLI to interact with other agents, expanding
|
||||
its capabilities by delegating tasks to remote services.
|
||||
|
||||
Gemini CLI can connect to any compliant A2A agent. You can find samples of A2A
|
||||
agents in the following repositories:
|
||||
|
||||
- [ADK Samples (Python)](https://github.com/google/adk-samples/tree/main/python)
|
||||
- [ADK Python Contributing Samples](https://github.com/google/adk-python/tree/main/contributing/samples)
|
||||
|
||||
> **Note: Remote subagents are currently an experimental feature.**
|
||||
|
||||
## Configuration
|
||||
|
||||
To use remote subagents, you must explicitly enable them in your
|
||||
`settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"enableAgents": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Defining remote subagents
|
||||
|
||||
Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter.
|
||||
You can place them in:
|
||||
|
||||
1. **Project-level:** `.gemini/agents/*.md` (Shared with your team)
|
||||
2. **User-level:** `~/.gemini/agents/*.md` (Personal agents)
|
||||
|
||||
### Configuration schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :--------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------- |
|
||||
| `kind` | string | Yes | Must be `remote`. |
|
||||
| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). |
|
||||
| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. |
|
||||
|
||||
### Single-subagent example
|
||||
|
||||
```markdown
|
||||
---
|
||||
kind: remote
|
||||
name: my-remote-agent
|
||||
agent_card_url: https://example.com/agent-card
|
||||
---
|
||||
```
|
||||
|
||||
### Multi-subagent example
|
||||
|
||||
The loader explicitly supports multiple remote subagents defined in a single
|
||||
Markdown file.
|
||||
|
||||
```markdown
|
||||
---
|
||||
- kind: remote
|
||||
name: remote-1
|
||||
agent_card_url: https://example.com/1
|
||||
- kind: remote
|
||||
name: remote-2
|
||||
agent_card_url: https://example.com/2
|
||||
---
|
||||
```
|
||||
|
||||
> **Note:** Mixed local and remote agents, or multiple local agents, are not
|
||||
> supported in a single file; the list format is currently remote-only.
|
||||
|
||||
## Managing Subagents
|
||||
|
||||
Users can manage subagents using the following commands within the Gemini CLI:
|
||||
|
||||
- `/agents list`: Displays all available local and remote subagents.
|
||||
- `/agents refresh`: Reloads the agent registry. Use this after adding or
|
||||
modifying agent definition files.
|
||||
- `/agents enable <agent_name>`: Enables a specific subagent.
|
||||
- `/agents disable <agent_name>`: Disables a specific subagent.
|
||||
|
||||
> **Tip:** You can use the `@cli_help` agent within Gemini CLI for assistance
|
||||
> with configuring subagents.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Sub-agents (experimental)
|
||||
|
||||
Sub-agents are specialized agents that operate within your main Gemini CLI
|
||||
session. They are designed to handle specific, complex tasks—like deep codebase
|
||||
analysis, documentation lookup, or domain-specific reasoning—without cluttering
|
||||
the main agent's context or toolset.
|
||||
|
||||
> **Note: Sub-agents are currently an experimental feature.**
|
||||
>
|
||||
> To use custom sub-agents, you must explicitly enable them in your
|
||||
> `settings.json`:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "experimental": { "enableAgents": true }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **Warning:** Sub-agents currently operate in
|
||||
> ["YOLO mode"](../get-started/configuration.md#command-line-arguments), meaning
|
||||
> they may execute tools without individual user confirmation for each step.
|
||||
> Proceed with caution when defining agents with powerful tools like
|
||||
> `run_shell_command` or `write_file`.
|
||||
|
||||
## What are sub-agents?
|
||||
|
||||
Think of sub-agents as "specialists" that the main Gemini agent can hire for a
|
||||
specific job.
|
||||
|
||||
- **Focused context:** Each sub-agent has its own system prompt and persona.
|
||||
- **Specialized tools:** Sub-agents can have a restricted or specialized set of
|
||||
tools.
|
||||
- **Independent context window:** Interactions with a sub-agent happen in a
|
||||
separate context loop. The main agent only sees the final result, saving
|
||||
tokens in your main conversation history.
|
||||
|
||||
Sub-agents are exposed to the main agent as a tool of the same name which
|
||||
delegates to the sub-agent, when called. Once the sub-agent completes its task
|
||||
(or fails), it reports back to the main agent with its findings (usually as a
|
||||
text summary or structured report returned by the tool).
|
||||
|
||||
## Built-in sub-agents
|
||||
|
||||
Gemini CLI comes with powerful built-in sub-agents.
|
||||
|
||||
### Codebase Investigator
|
||||
|
||||
- **Name:** `codebase_investigator`
|
||||
- **Purpose:** Deep analysis of the codebase, reverse engineering, and
|
||||
understanding complex dependencies.
|
||||
- **When to use:** "How does the authentication system work?", "Map out the
|
||||
dependencies of the `AgentRegistry` class."
|
||||
- **Configuration:** Enabled by default. You can configure it in
|
||||
`settings.json`. Example (forcing a specific model):
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"codebaseInvestigatorSettings": {
|
||||
"enabled": true,
|
||||
"maxNumTurns": 20,
|
||||
"model": "gemini-2.5-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Help Agent
|
||||
|
||||
- **Name:** `cli_help`
|
||||
- **Purpose:** Expert knowledge about Gemini CLI itself, its commands,
|
||||
configuration, and documentation.
|
||||
- **When to use:** "How do I configure a proxy?", "What does the `/rewind`
|
||||
command do?"
|
||||
- **Configuration:** Enabled by default.
|
||||
|
||||
## Creating custom sub-agents
|
||||
|
||||
You can create your own sub-agents to automate specific workflows or enforce
|
||||
specific personas.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
To use custom sub-agents, you must enable them in your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"enableAgents": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Agent definition files
|
||||
|
||||
Custom agents are defined as Markdown files (`.md`) with YAML frontmatter. You
|
||||
can place them in:
|
||||
|
||||
1. **Project-level:** `.gemini/agents/*.md` (Shared with your team)
|
||||
2. **User-level:** `~/.gemini/agents/*.md` (Personal agents)
|
||||
|
||||
### File format
|
||||
|
||||
The file **MUST** start with YAML frontmatter enclosed in triple-dashes `---`.
|
||||
The body of the markdown file becomes the agent's **System Prompt**.
|
||||
|
||||
**Example: `.gemini/agents/security-auditor.md`**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: security-auditor
|
||||
description: Specialized in finding security vulnerabilities in code.
|
||||
kind: local
|
||||
tools:
|
||||
- read_file
|
||||
- search_file_content
|
||||
model: gemini-2.5-pro
|
||||
temperature: 0.2
|
||||
max_turns: 10
|
||||
---
|
||||
|
||||
You are a ruthless Security Auditor. Your job is to analyze code for potential
|
||||
vulnerabilities.
|
||||
|
||||
Focus on:
|
||||
|
||||
1. SQL Injection
|
||||
2. XSS (Cross-Site Scripting)
|
||||
3. Hardcoded credentials
|
||||
4. Unsafe file operations
|
||||
|
||||
When you find a vulnerability, explain it clearly and suggest a fix. Do not fix
|
||||
it yourself; just report it.
|
||||
```
|
||||
|
||||
### Configuration schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
|
||||
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this sub-agent. |
|
||||
| `kind` | string | No | `local` (default) or `remote`. |
|
||||
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. |
|
||||
|
||||
### Optimizing your sub-agent
|
||||
|
||||
The main agent system prompt contains language that encourages use of an expert
|
||||
sub-agent when one is available for the task at hand. It decides whether an
|
||||
agent is a relevant expert based on the agent's description. You can improve the
|
||||
reliability with which an agent is used by updating the description to more
|
||||
clearly indicate:
|
||||
|
||||
- Its area of expertise.
|
||||
- When it should be used.
|
||||
- Some example scenarios.
|
||||
|
||||
For example: the following sub-agent description should be called fairly
|
||||
consistently for Git operations.
|
||||
|
||||
> Git expert agent which should be used for all local and remote git operations.
|
||||
> For example:
|
||||
>
|
||||
> - Making commits
|
||||
> - Searching for regressions with bisect
|
||||
> - Interacting with source control and issues providers such as GitHub.
|
||||
|
||||
If you need to further tune your sub-agent, you can do so by selecting the model
|
||||
to optimize for with `/model` and then asking the model why it does not think
|
||||
that your sub-agent was called with a specific prompt and the given description.
|
||||
|
||||
## Remote subagents (Agent2Agent)
|
||||
|
||||
Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
|
||||
(A2A) protocol.
|
||||
|
||||
See the [Remote Subagents documentation](/docs/core/remote-agents) for detailed
|
||||
configuration and usage instructions.
|
||||
|
||||
## Extension sub-agents
|
||||
|
||||
Extensions can bundle and distribute sub-agents. See the
|
||||
[Extensions documentation](../extensions/index.md#sub-agents) for details on how
|
||||
to package agents within an extension.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Extensions on Gemini CLI: Best practices
|
||||
|
||||
This guide covers best practices for developing, securing, and maintaining
|
||||
Gemini CLI extensions.
|
||||
|
||||
## Development
|
||||
|
||||
Developing extensions for Gemini CLI is intended to be a lightweight, iterative
|
||||
process.
|
||||
|
||||
### Structure your extension
|
||||
|
||||
While simple extensions can just be a few files, we recommend a robust structure
|
||||
for complex extensions:
|
||||
|
||||
```
|
||||
my-extension/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── gemini-extension.json
|
||||
├── src/
|
||||
│ ├── index.ts
|
||||
│ └── tools/
|
||||
└── dist/
|
||||
```
|
||||
|
||||
- **Use TypeScript**: We strongly recommend using TypeScript for type safety and
|
||||
better tooling.
|
||||
- **Separate source and build**: Keep your source code in `src` and build to
|
||||
`dist`.
|
||||
- **Bundle dependencies**: If your extension has many dependencies, consider
|
||||
bundling them (e.g., with `esbuild` or `webpack`) to reduce install time and
|
||||
potential conflicts.
|
||||
|
||||
### Iterate with `link`
|
||||
|
||||
Use `gemini extensions link` to develop locally without constantly reinstalling:
|
||||
|
||||
```bash
|
||||
cd my-extension
|
||||
gemini extensions link .
|
||||
```
|
||||
|
||||
Changes to your code (after rebuilding) will be immediately available in the CLI
|
||||
on restart.
|
||||
|
||||
### Use `GEMINI.md` effectively
|
||||
|
||||
Your `GEMINI.md` file provides context to the model. Keep it focused:
|
||||
|
||||
- **Do:** Explain high-level goals and how to use the provided tools.
|
||||
- **Don't:** Dump your entire documentation.
|
||||
- **Do:** Use clear, concise language.
|
||||
|
||||
## Security
|
||||
|
||||
When building a Gemini CLI extension, follow general security best practices
|
||||
(such as least privilege and input validation) to reduce risk.
|
||||
|
||||
### Minimal permissions
|
||||
|
||||
When defining tools in your MCP server, only request the permissions necessary.
|
||||
Avoid giving the model broad access (like full shell access) if a more
|
||||
restricted set of tools will suffice.
|
||||
|
||||
If you must use powerful tools like `run_shell_command`, consider restricting
|
||||
them to specific commands in your `gemini-extension.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-safe-extension",
|
||||
"excludeTools": ["run_shell_command(rm -rf *)"]
|
||||
}
|
||||
```
|
||||
|
||||
This ensures that even if the model tries to execute a dangerous command, it
|
||||
will be blocked at the CLI level.
|
||||
|
||||
### Validate inputs
|
||||
|
||||
Your MCP server is running on the user's machine. Always validate inputs to your
|
||||
tools to prevent arbitrary code execution or filesystem access outside the
|
||||
intended scope.
|
||||
|
||||
```typescript
|
||||
// Good: Validating paths
|
||||
if (!path.resolve(inputPath).startsWith(path.resolve(allowedDir) + path.sep)) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
```
|
||||
|
||||
### Sensitive settings
|
||||
|
||||
If your extension requires API keys, use the `sensitive: true` option in
|
||||
`gemini-extension.json`. This ensures keys are stored securely in the system
|
||||
keychain and obfuscated in the UI.
|
||||
|
||||
```json
|
||||
"settings": [
|
||||
{
|
||||
"name": "API Key",
|
||||
"envVar": "MY_API_KEY",
|
||||
"sensitive": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
You can upload your extension directly to GitHub to list it in the gallery.
|
||||
Gemini CLI extensions also offers support for more complicated
|
||||
[releases](releasing.md).
|
||||
|
||||
### Semantic versioning
|
||||
|
||||
Follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
- **Major**: Breaking changes (renaming tools, changing arguments).
|
||||
- **Minor**: New features (new tools, commands).
|
||||
- **Patch**: Bug fixes.
|
||||
|
||||
### Release Channels
|
||||
|
||||
Use git branches to manage release channels (e.g., `main` for stable, `dev` for
|
||||
bleeding edge). This allows users to choose their stability level:
|
||||
|
||||
```bash
|
||||
# Stable
|
||||
gemini extensions install github.com/user/repo
|
||||
|
||||
# Dev
|
||||
gemini extensions install github.com/user/repo --ref dev
|
||||
```
|
||||
|
||||
### Clean artifacts
|
||||
|
||||
If you are using GitHub Releases, ensure your release artifacts only contain the
|
||||
necessary files (`dist/`, `gemini-extension.json`, `package.json`). Exclude
|
||||
`node_modules` (users will install them) and `src/` to keep downloads small.
|
||||
+27
-359
@@ -1,377 +1,45 @@
|
||||
# Gemini CLI extensions
|
||||
|
||||
_This documentation is up-to-date with the v0.4.0 release._
|
||||
|
||||
Gemini CLI extensions package prompts, MCP servers, Agent Skills, and custom
|
||||
commands into a familiar and user-friendly format. With extensions, you can
|
||||
expand the capabilities of Gemini CLI and share those capabilities with others.
|
||||
They're designed to be easily installable and shareable.
|
||||
Gemini CLI extensions package prompts, MCP servers, custom commands, hooks,
|
||||
sub-agents, and agent skills into a familiar and user-friendly format. With
|
||||
extensions, you can expand the capabilities of Gemini CLI and share those
|
||||
capabilities with others. They are designed to be easily installable and
|
||||
shareable.
|
||||
|
||||
To see examples of extensions, you can browse a gallery of
|
||||
[Gemini CLI extensions](https://geminicli.com/extensions/browse/).
|
||||
|
||||
See [getting started docs](getting-started-extensions.md) for a guide on
|
||||
creating your first extension.
|
||||
## Managing extensions
|
||||
|
||||
See [releasing docs](extension-releasing.md) for an advanced guide on setting up
|
||||
GitHub releases.
|
||||
You can verify your installed extensions and their status using the interactive
|
||||
command:
|
||||
|
||||
## Extension management
|
||||
|
||||
We offer a suite of extension management tools using `gemini extensions`
|
||||
commands.
|
||||
|
||||
Note that these commands are not supported from within the CLI, although you can
|
||||
list installed extensions using the `/extensions list` subcommand.
|
||||
|
||||
Note that all of these commands will only be reflected in active CLI sessions on
|
||||
restart.
|
||||
|
||||
### Installing an extension
|
||||
|
||||
You can install an extension using `gemini extensions install` with either a
|
||||
GitHub URL or a local path.
|
||||
|
||||
Note that we create a copy of the installed extension, so you will need to run
|
||||
`gemini extensions update` to pull in changes from both locally-defined
|
||||
extensions and those on GitHub.
|
||||
|
||||
NOTE: If you are installing an extension from GitHub, you'll need to have `git`
|
||||
installed on your machine. See
|
||||
[git installation instructions](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||
for help.
|
||||
|
||||
```
|
||||
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
|
||||
```bash
|
||||
/extensions list
|
||||
```
|
||||
|
||||
- `<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.
|
||||
or in noninteractive mode:
|
||||
|
||||
### Uninstalling an extension
|
||||
|
||||
To uninstall one or more extensions, run
|
||||
`gemini extensions uninstall <name...>`:
|
||||
|
||||
```
|
||||
gemini extensions uninstall gemini-cli-security gemini-cli-another-extension
|
||||
```
|
||||
|
||||
### Disabling an extension
|
||||
|
||||
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`).
|
||||
|
||||
### 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.
|
||||
|
||||
```
|
||||
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`).
|
||||
|
||||
### 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>`.
|
||||
|
||||
You can update all extensions with:
|
||||
|
||||
```
|
||||
gemini extensions update --all
|
||||
```
|
||||
|
||||
### Create a boilerplate extension
|
||||
|
||||
We offer several example extensions `context`, `custom-commands`,
|
||||
`exclude-tools` and `mcp-server`. You can view these examples
|
||||
[here](https://github.com/google-gemini/gemini-cli/tree/main/packages/cli/src/commands/extensions/examples).
|
||||
|
||||
To copy one of these examples into a development directory using the type of
|
||||
your choosing, run:
|
||||
|
||||
```
|
||||
gemini extensions new <path> [template]
|
||||
```
|
||||
|
||||
- `<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
|
||||
extension installation directory to the development path.
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
- `<path>`: The path of the extension to link.
|
||||
|
||||
## How it works
|
||||
|
||||
On startup, Gemini CLI looks for extensions in `<home>/.gemini/extensions`
|
||||
|
||||
Extensions exist as a directory that contains a `gemini-extension.json` file.
|
||||
For example:
|
||||
|
||||
`<home>/.gemini/extensions/my-extension/gemini-extension.json`
|
||||
|
||||
### `gemini-extension.json`
|
||||
|
||||
The `gemini-extension.json` file contains the configuration for the extension.
|
||||
The file has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the
|
||||
extension and for conflict resolution when extension commands have the same
|
||||
name as user or project commands. The name should be lowercase or numbers and
|
||||
use dashes instead of underscores or spaces. This is how users will refer to
|
||||
your extension in the CLI. Note that we expect this name to match the
|
||||
extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
|
||||
server, and the value is the server configuration. These servers will be
|
||||
loaded on startup just like MCP servers settings in a
|
||||
[`settings.json` file](../get-started/configuration.md). If both an extension
|
||||
and a `settings.json` file settings an MCP server with the same name, the
|
||||
server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for
|
||||
`trust`.
|
||||
- `contextFileName`: The name of the file that contains the context for the
|
||||
extension. This will be used to load the context from the extension directory.
|
||||
If this property is not used but a `GEMINI.md` file is present in your
|
||||
extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also
|
||||
specify command-specific restrictions for tools that support it, like the
|
||||
`run_shell_command` tool. For example,
|
||||
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
|
||||
command. Note that this differs from the MCP server `excludeTools`
|
||||
functionality, which can be listed in the MCP server config.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their
|
||||
configurations. If there are any conflicts, the workspace configuration takes
|
||||
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.
|
||||
|
||||
To define settings, add a `settings` array to your `gemini-extension.json` file.
|
||||
Each object in the array should have the following properties:
|
||||
|
||||
- `name`: A user-friendly name for the setting.
|
||||
- `description`: A description of the setting and what it's used for.
|
||||
- `envVar`: The name of the environment variable that the setting will be stored
|
||||
as.
|
||||
- `sensitive`: Optional boolean. If true, obfuscates the input the user provides
|
||||
and stores the secret in keychain storage. **Example**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-api-extension",
|
||||
"version": "1.0.0",
|
||||
"settings": [
|
||||
{
|
||||
"name": "API Key",
|
||||
"description": "Your API key for the service.",
|
||||
"envVar": "MY_API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When a user installs this extension, they will be prompted to enter their API
|
||||
key. The value will be saved to a `.env` file in the extension's directory
|
||||
(e.g., `<home>/.gemini/extensions/my-api-extension/.env`).
|
||||
|
||||
You can view a list of an extension's settings by running:
|
||||
|
||||
```
|
||||
```bash
|
||||
gemini extensions list
|
||||
```
|
||||
|
||||
and you can update a given setting using:
|
||||
## Installation
|
||||
|
||||
```
|
||||
gemini extensions config <extension name> [setting name] [--scope <scope>]
|
||||
To install a real extension, you can use the `extensions install` command with a
|
||||
GitHub repository URL in noninteractive mode. For example:
|
||||
|
||||
```bash
|
||||
gemini extensions install https://github.com/gemini-cli-extensions/workspace
|
||||
```
|
||||
|
||||
- `--scope`: The scope to set the setting in (`user` or `workspace`). This is
|
||||
optional and will default to `user`.
|
||||
## Next steps
|
||||
|
||||
### Custom commands
|
||||
|
||||
Extensions can provide [custom commands](../cli/custom-commands.md) by placing
|
||||
TOML files in a `commands/` subdirectory within the extension directory. These
|
||||
commands follow the same format as user and project custom commands and use
|
||||
standard naming conventions.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/gcp/
|
||||
├── gemini-extension.json
|
||||
└── commands/
|
||||
├── deploy.toml
|
||||
└── gcs/
|
||||
└── sync.toml
|
||||
```
|
||||
|
||||
Would provide these commands:
|
||||
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Agent Skills
|
||||
|
||||
_Note: This is an experimental feature enabled via `experimental.skills`._
|
||||
|
||||
Extensions can bundle [Agent Skills](../cli/skills.md) to provide on-demand
|
||||
expertise and specialized workflows. To include skills in your extension, place
|
||||
them in a `skills/` subdirectory within the extension directory. Each skill must
|
||||
follow the [Agent Skills structure](../cli/skills.md#folder-structure),
|
||||
including a `SKILL.md` file.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `security-toolkit` with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/security-toolkit/
|
||||
├── gemini-extension.json
|
||||
└── skills/
|
||||
├── audit/
|
||||
│ ├── SKILL.md
|
||||
│ └── scripts/
|
||||
│ └── scan.py
|
||||
└── hardening/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
Upon installation, these skills will be discovered by Gemini CLI and can be
|
||||
activated during a session when the model identifies a task matching their
|
||||
descriptions.
|
||||
|
||||
Extension skills have the lowest precedence and will be overridden by user or
|
||||
workspace skills of the same name. They can be viewed and managed (enabled or
|
||||
disabled) using the [`/skills` command](../cli/skills.md#managing-skills).
|
||||
|
||||
### Hooks
|
||||
|
||||
Extensions can provide [hooks](../hooks/index.md) to intercept and customize
|
||||
Gemini CLI behavior at specific lifecycle events. Hooks provided by an extension
|
||||
must be defined in a `hooks/hooks.json` file within the extension directory.
|
||||
|
||||
> [!IMPORTANT] Hooks are not defined directly in `gemini-extension.json`. The
|
||||
> CLI specifically looks for the `hooks/hooks.json` file.
|
||||
|
||||
#### Directory structure
|
||||
|
||||
```
|
||||
.gemini/extensions/my-extension/
|
||||
├── gemini-extension.json
|
||||
└── hooks/
|
||||
└── hooks.json
|
||||
```
|
||||
|
||||
#### `hooks/hooks.json` format
|
||||
|
||||
The `hooks.json` file contains a `hooks` object where keys are
|
||||
[event names](../hooks/reference.md#supported-events) and values are arrays of
|
||||
[hook definitions](../hooks/reference.md#hook-definition).
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"before_agent": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${extensionPath}/scripts/setup.js",
|
||||
"name": "Extension Setup"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Supported variables
|
||||
|
||||
Just like `gemini-extension.json`, the `hooks/hooks.json` file supports
|
||||
[variable substitution](#variables). This is particularly useful for referencing
|
||||
scripts within the extension directory using `${extensionPath}`.
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user
|
||||
or project commands:
|
||||
|
||||
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
|
||||
2. **With conflict**: Extension command is renamed with the extension prefix
|
||||
(e.g., `/gcp.deploy`)
|
||||
|
||||
For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]`
|
||||
tag)
|
||||
|
||||
### Variables
|
||||
|
||||
Gemini CLI extensions allow variable substitution in both
|
||||
`gemini-extension.json` and `hooks/hooks.json`. This can be useful if e.g., you
|
||||
need the current directory to run an MCP server or hook script using
|
||||
`"cwd": "${extensionPath}${/}run.ts"`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
| variable | description |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${workspacePath}` | The fully-qualified path of the current workspace. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
| `${process.execPath}` | The path to the Node.js binary executing the CLI. |
|
||||
- [Writing extensions](writing-extensions.md): Learn how to create your first
|
||||
extension.
|
||||
- [Extensions reference](reference.md): Deeply understand the extension format,
|
||||
commands, and configuration.
|
||||
- [Best practices](best-practices.md): Learn strategies for building great
|
||||
extensions.
|
||||
- [Extensions releasing](releasing.md): Learn how to share your extensions with
|
||||
the world.
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
# Extensions reference
|
||||
|
||||
This guide covers the `gemini extensions` commands and the structure of the
|
||||
`gemini-extension.json` configuration file.
|
||||
|
||||
## Extension management
|
||||
|
||||
We offer a suite of extension management tools using `gemini extensions`
|
||||
commands.
|
||||
|
||||
Note that these commands (e.g. `gemini extensions install`) are not supported
|
||||
from within the CLI's **interactive mode**, although you can list installed
|
||||
extensions using the `/extensions list` slash command.
|
||||
|
||||
Note that all of these management operations (including updates to slash
|
||||
commands) will only be reflected in active CLI sessions on **restart**.
|
||||
|
||||
### Installing an extension
|
||||
|
||||
You can install an extension using `gemini extensions install` with either a
|
||||
GitHub URL or a local path.
|
||||
|
||||
Note that we create a copy of the installed extension, so you will need to run
|
||||
`gemini extensions update` to pull in changes from both locally-defined
|
||||
extensions and those on GitHub.
|
||||
|
||||
NOTE: If you are installing an extension from GitHub, you'll need to have `git`
|
||||
installed on your machine. See
|
||||
[git installation instructions](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||
for help.
|
||||
|
||||
```
|
||||
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
|
||||
```
|
||||
|
||||
- `<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.
|
||||
|
||||
### Uninstalling an extension
|
||||
|
||||
To uninstall one or more extensions, run
|
||||
`gemini extensions uninstall <name...>`:
|
||||
|
||||
```
|
||||
gemini extensions uninstall gemini-cli-security gemini-cli-another-extension
|
||||
```
|
||||
|
||||
### Disabling an extension
|
||||
|
||||
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`).
|
||||
|
||||
### 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.
|
||||
|
||||
```
|
||||
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`).
|
||||
|
||||
### 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>`.
|
||||
|
||||
You can update all extensions with:
|
||||
|
||||
```
|
||||
gemini extensions update --all
|
||||
```
|
||||
|
||||
### Create a boilerplate extension
|
||||
|
||||
We offer several example extensions `context`, `custom-commands`,
|
||||
`exclude-tools` and `mcp-server`. You can view these examples
|
||||
[here](https://github.com/google-gemini/gemini-cli/tree/main/packages/cli/src/commands/extensions/examples).
|
||||
|
||||
To copy one of these examples into a development directory using the type of
|
||||
your choosing, run:
|
||||
|
||||
```
|
||||
gemini extensions new <path> [template]
|
||||
```
|
||||
|
||||
- `<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
|
||||
extension installation directory to the development path.
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
- `<path>`: The path of the extension to link.
|
||||
|
||||
## Extension format
|
||||
|
||||
On startup, Gemini CLI looks for extensions in `<home>/.gemini/extensions`
|
||||
|
||||
Extensions exist as a directory that contains a `gemini-extension.json` file.
|
||||
For example:
|
||||
|
||||
`<home>/.gemini/extensions/my-extension/gemini-extension.json`
|
||||
|
||||
### `gemini-extension.json`
|
||||
|
||||
The `gemini-extension.json` file contains the configuration for the extension.
|
||||
The file has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.0.0",
|
||||
"description": "My awesome extension",
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node my-server.js"
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the
|
||||
extension and for conflict resolution when extension commands have the same
|
||||
name as user or project commands. The name should be lowercase or numbers and
|
||||
use dashes instead of underscores or spaces. This is how users will refer to
|
||||
your extension in the CLI. Note that we expect this name to match the
|
||||
extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `description`: A short description of the extension. This will be displayed on
|
||||
[geminicli.com/extensions](https://geminicli.com/extensions).
|
||||
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
|
||||
server, and the value is the server configuration. These servers will be
|
||||
loaded on startup just like MCP servers settingsd in a
|
||||
[`settings.json` file](../get-started/configuration.md). If both an extension
|
||||
and a `settings.json` file settings an MCP server with the same name, the
|
||||
server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for
|
||||
`trust`.
|
||||
- `contextFileName`: The name of the file that contains the context for the
|
||||
extension. This will be used to load the context from the extension directory.
|
||||
If this property is not used but a `GEMINI.md` file is present in your
|
||||
extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also
|
||||
specify command-specific restrictions for tools that support it, like the
|
||||
`run_shell_command` tool. For example,
|
||||
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
|
||||
command. Note that this differs from the MCP server `excludeTools`
|
||||
functionality, which can be listed in the MCP server config.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their
|
||||
configurations. If there are any conflicts, the workspace configuration takes
|
||||
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.
|
||||
|
||||
To define settings, add a `settings` array to your `gemini-extension.json` file.
|
||||
Each object in the array should have the following properties:
|
||||
|
||||
- `name`: A user-friendly name for the setting.
|
||||
- `description`: A description of the setting and what it's used for.
|
||||
- `envVar`: The name of the environment variable that the setting will be stored
|
||||
as.
|
||||
- `sensitive`: Optional boolean. If true, obfuscates the input the user provides
|
||||
and stores the secret in keychain storage. **Example**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-api-extension",
|
||||
"version": "1.0.0",
|
||||
"settings": [
|
||||
{
|
||||
"name": "API Key",
|
||||
"description": "Your API key for the service.",
|
||||
"envVar": "MY_API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When a user installs this extension, they will be prompted to enter their API
|
||||
key. The value will be saved to a `.env` file in the extension's directory
|
||||
(e.g., `<home>/.gemini/extensions/my-api-extension/.env`).
|
||||
|
||||
You can view a list of an extension's settings by running:
|
||||
|
||||
```
|
||||
gemini extensions list
|
||||
```
|
||||
|
||||
and you can update a given setting using:
|
||||
|
||||
```
|
||||
gemini extensions config <extension name> [setting name] [--scope <scope>]
|
||||
```
|
||||
|
||||
- `--scope`: The scope to set the setting in (`user` or `workspace`). This is
|
||||
optional and will default to `user`.
|
||||
|
||||
### Custom commands
|
||||
|
||||
Extensions can provide [custom commands](../cli/custom-commands.md) by placing
|
||||
TOML files in a `commands/` subdirectory within the extension directory. These
|
||||
commands follow the same format as user and project custom commands and use
|
||||
standard naming conventions.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/gcp/
|
||||
├── gemini-extension.json
|
||||
└── commands/
|
||||
├── deploy.toml
|
||||
└── gcs/
|
||||
└── sync.toml
|
||||
```
|
||||
|
||||
Would provide these commands:
|
||||
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Hooks
|
||||
|
||||
Extensions can provide [hooks](../hooks/index.md) to intercept and customize
|
||||
Gemini CLI behavior at specific lifecycle events. Hooks provided by an extension
|
||||
must be defined in a `hooks/hooks.json` file within the extension directory.
|
||||
|
||||
> [!IMPORTANT] Hooks are not defined directly in `gemini-extension.json`. The
|
||||
> CLI specifically looks for the `hooks/hooks.json` file.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
Extensions can bundle [Agent Skills](../cli/skills.md) to provide specialized
|
||||
workflows. Skills must be placed in a `skills/` directory within the extension.
|
||||
|
||||
**Example**
|
||||
|
||||
An extension with the following structure:
|
||||
|
||||
```
|
||||
.gemini/extensions/my-extension/
|
||||
├── gemini-extension.json
|
||||
└── skills/
|
||||
└── security-audit/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
Will expose a `security-audit` skill that the model can activate.
|
||||
|
||||
### Sub-agents
|
||||
|
||||
> **Note: Sub-agents are currently an experimental feature.**
|
||||
|
||||
Extensions can provide [sub-agents](../core/subagents.md) that users can
|
||||
delegate tasks to.
|
||||
|
||||
To bundle sub-agents with your extension, create an `agents/` directory in your
|
||||
extension's root folder and add your agent definition files (`.md`) there.
|
||||
|
||||
**Example**
|
||||
|
||||
```
|
||||
.gemini/extensions/my-extension/
|
||||
├── gemini-extension.json
|
||||
└── agents/
|
||||
├── security-auditor.md
|
||||
└── database-expert.md
|
||||
```
|
||||
|
||||
Gemini CLI will automatically discover and load these agents when the extension
|
||||
is installed and enabled.
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user
|
||||
or project commands:
|
||||
|
||||
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
|
||||
2. **With conflict**: Extension command is renamed with the extension prefix
|
||||
(e.g., `/gcp.deploy`)
|
||||
|
||||
For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]`
|
||||
tag)
|
||||
|
||||
## Variables
|
||||
|
||||
Gemini CLI extensions allow variable substitution in both
|
||||
`gemini-extension.json` and `hooks/hooks.json`. This can be useful if e.g., you
|
||||
need the current directory to run an MCP server using an argument like
|
||||
`"args": ["${extensionPath}${/}dist${/}server.js"]`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
| variable | description |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. |
|
||||
| `${workspacePath}` | The fully-qualified path of the current workspace. |
|
||||
| `${/} or ${pathSeparator}` | The path separator (differs per OS). |
|
||||
+28
-29
@@ -8,7 +8,19 @@ file.
|
||||
## Prerequisites
|
||||
|
||||
Before you start, make sure you have the Gemini CLI installed and a basic
|
||||
understanding of Node.js and TypeScript.
|
||||
understanding of Node.js.
|
||||
|
||||
## When to use what
|
||||
|
||||
Extensions offer a variety of ways to customize Gemini CLI.
|
||||
|
||||
| Feature | What it is | When to use it | Invoked by |
|
||||
| :------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------- |
|
||||
| **[MCP server](reference.md#mcp-servers)** | A standard way to expose new tools and data sources to the model. | Use this when you want the model to be able to _do_ new things, like fetching data from an internal API, querying a database, or controlling a local application. We also support MCP resources (which can replace custom commands) and system instructions (which can replace custom context) | Model |
|
||||
| **[Custom commands](../cli/custom-commands.md)** | A shortcut (like `/my-cmd`) that executes a pre-defined prompt or shell command. | Use this for repetitive tasks or to save long, complex prompts that you use frequently. Great for automation. | User |
|
||||
| **[Context file (`GEMINI.md`)](reference.md#contextfilename)** | A markdown file containing instructions that are loaded into the model's context at the start of every session. | Use this to define the "personality" of your extension, set coding standards, or provide essential knowledge that the model should always have. | CLI provides to model |
|
||||
| **[Agent skills](../cli/skills.md)** | A specialized set of instructions and workflows that the model activates only when needed. | Use this for complex, occasional tasks (like "create a PR" or "audit security") to avoid cluttering the main context window when the skill isn't being used. | Model |
|
||||
| **[Hooks](../hooks/index.md)** | A way to intercept and customize the CLI's behavior at specific lifecycle events (e.g., before/after a tool call). | Use this when you want to automate actions based on what the model is doing, like validating tool arguments, logging activity, or modifying the model's input/output. | CLI |
|
||||
|
||||
## Step 1: Create a new extension
|
||||
|
||||
@@ -26,10 +38,9 @@ This will create a new directory with the following structure:
|
||||
|
||||
```
|
||||
my-first-extension/
|
||||
├── example.ts
|
||||
├── example.js
|
||||
├── gemini-extension.json
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Step 2: Understand the extension files
|
||||
@@ -43,12 +54,12 @@ and use your extension.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-first-extension",
|
||||
"name": "mcp-server-example",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"args": ["${extensionPath}${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
@@ -64,12 +75,12 @@ and use your extension.
|
||||
with the absolute path to your extension's installation directory. This
|
||||
allows your extension to work regardless of where it's installed.
|
||||
|
||||
### `example.ts`
|
||||
### `example.js`
|
||||
|
||||
This file contains the source code for your MCP server. It's a simple Node.js
|
||||
server that uses the `@modelcontextprotocol/sdk`.
|
||||
|
||||
```typescript
|
||||
```javascript
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
@@ -118,16 +129,15 @@ await server.connect(transport);
|
||||
This server defines a single tool called `fetch_posts` that fetches data from a
|
||||
public API.
|
||||
|
||||
### `package.json` and `tsconfig.json`
|
||||
### `package.json`
|
||||
|
||||
These are standard configuration files for a TypeScript project. The
|
||||
`package.json` file defines dependencies and a `build` script, and
|
||||
`tsconfig.json` configures the TypeScript compiler.
|
||||
This is the standard configuration file for a Node.js project. It defines
|
||||
dependencies and scripts.
|
||||
|
||||
## Step 3: Build and link your extension
|
||||
## Step 3: Link your extension
|
||||
|
||||
Before you can use the extension, you need to compile the TypeScript code and
|
||||
link the extension to your Gemini CLI installation for local development.
|
||||
Before you can use the extension, you need to link it to your Gemini CLI
|
||||
installation for local development.
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
@@ -136,16 +146,7 @@ link the extension to your Gemini CLI installation for local development.
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Build the server:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This will compile `example.ts` into `dist/example.js`, which is the file
|
||||
referenced in your `gemini-extension.json`.
|
||||
|
||||
3. **Link the extension:**
|
||||
2. **Link the extension:**
|
||||
|
||||
The `link` command creates a symbolic link from the Gemini CLI extensions
|
||||
directory to your development directory. This means any changes you make
|
||||
@@ -212,7 +213,7 @@ need this for extensions built to expose commands and prompts.
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"args": ["${extensionPath}${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
@@ -224,8 +225,6 @@ file in every session where the extension is active.
|
||||
|
||||
## (Optional) Step 6: Add an Agent Skill
|
||||
|
||||
_Note: This is an experimental feature enabled via `experimental.skills`._
|
||||
|
||||
[Agent Skills](../cli/skills.md) let you bundle specialized expertise and
|
||||
procedural workflows. Unlike `GEMINI.md`, which provides persistent context,
|
||||
skills are activated only when needed, saving context tokens.
|
||||
@@ -265,7 +264,7 @@ primary ways of releasing extensions are via a Git repository or through GitHub
|
||||
Releases. Using a public Git repository is the simplest method.
|
||||
|
||||
For detailed instructions on both methods, please refer to the
|
||||
[Extension Releasing Guide](./extension-releasing.md).
|
||||
[Extension Releasing Guide](./releasing.md).
|
||||
|
||||
## Conclusion
|
||||
|
||||
@@ -244,16 +244,16 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Show the model name in the chat for each model turn.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`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:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.useBackgroundColor`** (boolean):
|
||||
- **Description:** Whether to use background colors in the UI.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.incrementalRendering`** (boolean):
|
||||
- **Description:** Enable incremental rendering for the UI. This option will
|
||||
reduce flickering but may cause rendering artifacts. Only supported when
|
||||
@@ -261,6 +261,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.showSpinner`** (boolean):
|
||||
- **Description:** Show the spinner during operations.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.customWittyPhrases`** (array):
|
||||
- **Description:** Custom witty phrases to display during loading. When
|
||||
provided, the CLI cycles through these instead of the defaults.
|
||||
@@ -612,6 +616,14 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`context.fileFiltering.customIgnoreFilePaths`** (array):
|
||||
- **Description:** Additional ignore file paths to respect. These files take
|
||||
precedence over .geminiignore and .gitignore. Files earlier in the array
|
||||
take precedence over files later in the array, e.g. the first file takes
|
||||
precedence over the second one.
|
||||
- **Default:** `[]`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `tools`
|
||||
|
||||
- **`tools.sandbox`** (boolean | string):
|
||||
@@ -650,6 +662,13 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
considered safe (e.g., read-only operations).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`tools.approvalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
read-only mode. 'yolo' is not supported yet.
|
||||
- **Default:** `"default"`
|
||||
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
|
||||
|
||||
- **`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
|
||||
@@ -837,7 +856,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.enableEventDrivenScheduler`** (boolean):
|
||||
- **Description:** Enables event-driven scheduler within the CLI session.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionReloading`** (boolean):
|
||||
@@ -851,47 +870,15 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.skills`** (boolean):
|
||||
- **Description:** Enable Agent Skills (experimental).
|
||||
- **Description:** [Deprecated] Enable Agent Skills (experimental).
|
||||
- **Default:** `false`
|
||||
- **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:** `"auto"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.useOSC52Paste`** (boolean):
|
||||
- **Description:** Use OSC 52 sequence for pasting instead of clipboardy
|
||||
(useful for remote sessions).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.cliHelpAgentSettings.enabled`** (boolean):
|
||||
- **Description:** Enable the CLI Help Agent.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.plan`** (boolean):
|
||||
- **Description:** Enable planning features (Plan Mode and tools).
|
||||
- **Default:** `false`
|
||||
@@ -899,6 +886,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `skills`
|
||||
|
||||
- **`skills.enabled`** (boolean):
|
||||
- **Description:** Enable Agent Skills.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`skills.disabled`** (array):
|
||||
- **Description:** List of disabled skills.
|
||||
- **Default:** `[]`
|
||||
@@ -909,7 +901,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **`hooksConfig.enabled`** (boolean):
|
||||
- **Description:** Canonical toggle for the hooks system. When disabled, no
|
||||
hooks will be executed.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`hooksConfig.disabled`** (array):
|
||||
- **Description:** List of hook names (commands) that should be disabled.
|
||||
|
||||
+252
-431
@@ -1,4 +1,4 @@
|
||||
# Hooks on Gemini CLI: Best practices
|
||||
# Hooks Best Practices
|
||||
|
||||
This guide covers security considerations, performance optimization, debugging
|
||||
techniques, and privacy considerations for developing and deploying hooks in
|
||||
@@ -15,21 +15,20 @@ using parallel operations:
|
||||
// Sequential operations are slower
|
||||
const data1 = await fetch(url1).then((r) => r.json());
|
||||
const data2 = await fetch(url2).then((r) => r.json());
|
||||
const data3 = await fetch(url3).then((r) => r.json());
|
||||
|
||||
// Prefer parallel operations for better performance
|
||||
// Start requests concurrently
|
||||
const p1 = fetch(url1).then((r) => r.json());
|
||||
const p2 = fetch(url2).then((r) => r.json());
|
||||
const p3 = fetch(url3).then((r) => r.json());
|
||||
|
||||
// Wait for all results
|
||||
const [data1, data2, data3] = await Promise.all([p1, p2, p3]);
|
||||
const [data1, data2] = await Promise.all([p1, p2]);
|
||||
```
|
||||
|
||||
### Cache expensive operations
|
||||
|
||||
Store results between invocations to avoid repeated computation:
|
||||
Store results between invocations to avoid repeated computation, especially for
|
||||
hooks that run frequently (like `BeforeTool` or `AfterModel`).
|
||||
|
||||
```javascript
|
||||
const fs = require('fs');
|
||||
@@ -54,6 +53,7 @@ async function main() {
|
||||
const cacheKey = `tool-list-${(Date.now() / 3600000) | 0}`; // Hourly cache
|
||||
|
||||
if (cache[cacheKey]) {
|
||||
// Write JSON to stdout
|
||||
console.log(JSON.stringify(cache[cacheKey]));
|
||||
return;
|
||||
}
|
||||
@@ -70,32 +70,20 @@ async function main() {
|
||||
### Use appropriate events
|
||||
|
||||
Choose hook events that match your use case to avoid unnecessary execution.
|
||||
`AfterAgent` fires once per agent loop completion, while `AfterModel` fires
|
||||
after every LLM call (potentially multiple times per loop):
|
||||
|
||||
```json
|
||||
// If checking final completion, use AfterAgent instead of AfterModel
|
||||
{
|
||||
"hooks": {
|
||||
"AfterAgent": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "final-checker",
|
||||
"command": "./check-completion.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
- **`AfterAgent`**: Fires **once** per turn after the model finishes its final
|
||||
response. Use this for quality validation (Retries) or final logging.
|
||||
- **`AfterModel`**: Fires after **every chunk** of LLM output. Use this for
|
||||
real-time redaction, PII filtering, or monitoring output as it streams.
|
||||
|
||||
If you only need to check the final completion, use `AfterAgent` to save
|
||||
performance.
|
||||
|
||||
### Filter with matchers
|
||||
|
||||
Use specific matchers to avoid unnecessary hook execution. Instead of matching
|
||||
all tools with `*`, specify only the tools you need:
|
||||
all tools with `*`, specify only the tools you need. This saves the overhead of
|
||||
spawning a process for irrelevant events.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -103,6 +91,7 @@ all tools with `*`, specify only the tools you need:
|
||||
"hooks": [
|
||||
{
|
||||
"name": "validate-writes",
|
||||
"type": "command",
|
||||
"command": "./validate.sh"
|
||||
}
|
||||
]
|
||||
@@ -111,30 +100,32 @@ all tools with `*`, specify only the tools you need:
|
||||
|
||||
### Optimize JSON parsing
|
||||
|
||||
For large inputs, use streaming JSON parsers to avoid loading everything into
|
||||
memory:
|
||||
|
||||
```javascript
|
||||
// Standard approach: parse entire input
|
||||
const input = JSON.parse(await readStdin());
|
||||
const content = input.tool_input.content;
|
||||
|
||||
// For very large inputs: stream and extract only needed fields
|
||||
const { createReadStream } = require('fs');
|
||||
const JSONStream = require('JSONStream');
|
||||
|
||||
const stream = createReadStream(0).pipe(JSONStream.parse('tool_input.content'));
|
||||
let content = '';
|
||||
stream.on('data', (chunk) => {
|
||||
content += chunk;
|
||||
});
|
||||
```
|
||||
For large inputs (like `AfterModel` receiving a large context), standard JSON
|
||||
parsing can be slow. If you only need one field, consider streaming parsers or
|
||||
lightweight extraction logic, though for most shell scripts `jq` is sufficient.
|
||||
|
||||
## Debugging
|
||||
|
||||
### The "Strict JSON" rule
|
||||
|
||||
The most common cause of hook failure is "polluting" the standard output.
|
||||
|
||||
- **stdout** is for **JSON only**.
|
||||
- **stderr** is for **logs and text**.
|
||||
|
||||
**Good:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo "Starting check..." >&2 # <--- Redirect to stderr
|
||||
echo '{"decision": "allow"}'
|
||||
|
||||
```
|
||||
|
||||
### Log to files
|
||||
|
||||
Write debug information to dedicated log files:
|
||||
Since hooks run in the background, writing to a dedicated log file is often the
|
||||
easiest way to debug complex logic.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
@@ -151,6 +142,9 @@ log "Received input: ${input:0:100}..."
|
||||
# Hook logic here
|
||||
|
||||
log "Hook completed successfully"
|
||||
# Always output valid JSON to stdout at the end, even if just empty
|
||||
echo "{}"
|
||||
|
||||
```
|
||||
|
||||
### Use stderr for errors
|
||||
@@ -162,6 +156,7 @@ try {
|
||||
const result = dangerousOperation();
|
||||
console.log(JSON.stringify({ result }));
|
||||
} catch (error) {
|
||||
// Write the error description to stderr so the user/agent sees it
|
||||
console.error(`Hook error: ${error.message}`);
|
||||
process.exit(2); // Blocking error
|
||||
}
|
||||
@@ -169,7 +164,8 @@ try {
|
||||
|
||||
### Test hooks independently
|
||||
|
||||
Run hook scripts manually with sample JSON input:
|
||||
Run hook scripts manually with sample JSON input to verify they behave as
|
||||
expected before hooking them up to the CLI.
|
||||
|
||||
```bash
|
||||
# Create test input
|
||||
@@ -191,33 +187,46 @@ cat test-input.json | .gemini/hooks/my-hook.sh
|
||||
|
||||
# Check exit code
|
||||
echo "Exit code: $?"
|
||||
|
||||
```
|
||||
|
||||
### Check exit codes
|
||||
|
||||
Ensure your script returns the correct exit code:
|
||||
Gemini CLI uses exit codes for high-level flow control:
|
||||
|
||||
- **Exit 0 (Success)**: The hook ran successfully. The CLI parses `stdout` for
|
||||
JSON decisions.
|
||||
- **Exit 2 (System Block)**: A critical block occurred. `stderr` is used as the
|
||||
reason.
|
||||
- For **Agent/Model** events, this aborts the turn.
|
||||
- For **Tool** events, this blocks the tool but allows the agent to continue.
|
||||
- For **AfterAgent**, this triggers an automatic retry turn.
|
||||
|
||||
> **TIP**
|
||||
>
|
||||
> **Blocking vs. Stopping**: Use `decision: "deny"` (or Exit Code 2) to block a
|
||||
> **specific action**. Use `{"continue": false}` in your JSON output to **kill
|
||||
> the entire agent loop** immediately.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -e # Exit on error
|
||||
set -e
|
||||
|
||||
# Hook logic
|
||||
process_input() {
|
||||
# ...
|
||||
}
|
||||
|
||||
if process_input; then
|
||||
echo "Success message"
|
||||
echo '{"decision": "allow"}'
|
||||
exit 0
|
||||
else
|
||||
echo "Error message" >&2
|
||||
echo "Critical validation failure" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
```
|
||||
|
||||
### Enable telemetry
|
||||
|
||||
Hook execution is logged when `telemetry.logPrompts` is enabled:
|
||||
Hook execution is logged when `telemetry.logPrompts` is enabled. You can view
|
||||
these logs to debug execution flow.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -227,11 +236,10 @@ Hook execution is logged when `telemetry.logPrompts` is enabled:
|
||||
}
|
||||
```
|
||||
|
||||
View hook telemetry in logs to debug execution issues.
|
||||
|
||||
### Use hook panel
|
||||
|
||||
The `/hooks panel` command shows execution status and recent output:
|
||||
The `/hooks panel` command inside the CLI shows execution status and recent
|
||||
output:
|
||||
|
||||
```bash
|
||||
/hooks panel
|
||||
@@ -255,18 +263,64 @@ Begin with basic logging hooks before implementing complex logic:
|
||||
# Simple logging hook to understand input structure
|
||||
input=$(cat)
|
||||
echo "$input" >> .gemini/hook-inputs.log
|
||||
echo "Logged input"
|
||||
# Always return valid JSON
|
||||
echo "{}"
|
||||
|
||||
```
|
||||
|
||||
### Documenting your hooks
|
||||
|
||||
Maintainability is critical for complex hook systems. Use descriptions and
|
||||
comments to help yourself and others understand why a hook exists.
|
||||
|
||||
**Use the `description` field**: This text is displayed in the `/hooks panel` UI
|
||||
and helps diagnose issues.
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "secret-scanner",
|
||||
"type": "command",
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
|
||||
"description": "Scans code changes for API keys and secrets before writing"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Add comments in hook scripts**: Explain performance expectations and
|
||||
dependencies.
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* RAG Tool Filter Hook
|
||||
*
|
||||
* Reduces the tool space by extracting keywords from the user's request.
|
||||
*
|
||||
* Performance: ~500ms average
|
||||
* Dependencies: @google/generative-ai
|
||||
*/
|
||||
```
|
||||
|
||||
### Use JSON libraries
|
||||
|
||||
Parse JSON with proper libraries instead of text processing:
|
||||
Parse JSON with proper libraries instead of text processing.
|
||||
|
||||
**Bad:**
|
||||
|
||||
```bash
|
||||
# Fragile text parsing
|
||||
tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+')
|
||||
|
||||
```
|
||||
|
||||
**Good:**
|
||||
@@ -274,6 +328,7 @@ tool_name=$(echo "$input" | grep -oP '"tool_name":\s*"\K[^"]+')
|
||||
```bash
|
||||
# Robust JSON parsing
|
||||
tool_name=$(echo "$input" | jq -r '.tool_name')
|
||||
|
||||
```
|
||||
|
||||
### Make scripts executable
|
||||
@@ -283,6 +338,7 @@ Always make hook scripts executable:
|
||||
```bash
|
||||
chmod +x .gemini/hooks/*.sh
|
||||
chmod +x .gemini/hooks/*.js
|
||||
|
||||
```
|
||||
|
||||
### Version control
|
||||
@@ -292,7 +348,7 @@ Commit hooks to share with your team:
|
||||
```bash
|
||||
git add .gemini/hooks/
|
||||
git add .gemini/settings.json
|
||||
git commit -m "Add project hooks for security and testing"
|
||||
|
||||
```
|
||||
|
||||
**`.gitignore` considerations:**
|
||||
@@ -306,295 +362,10 @@ git commit -m "Add project hooks for security and testing"
|
||||
# Keep hook scripts
|
||||
!.gemini/hooks/*.sh
|
||||
!.gemini/hooks/*.js
|
||||
|
||||
```
|
||||
|
||||
### Document behavior
|
||||
|
||||
Add descriptions to help others understand your hooks:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "secret-scanner",
|
||||
"type": "command",
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
|
||||
"description": "Scans code changes for API keys, passwords, and other secrets before writing"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add comments in hook scripts:
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* RAG Tool Filter Hook
|
||||
*
|
||||
* This hook reduces the tool space from 100+ tools to ~15 relevant ones
|
||||
* by extracting keywords from the user's request and filtering tools
|
||||
* based on semantic similarity.
|
||||
*
|
||||
* Performance: ~500ms average, cached tool embeddings
|
||||
* Dependencies: @google/generative-ai
|
||||
*/
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook not executing
|
||||
|
||||
**Check hook name in `/hooks panel`:**
|
||||
|
||||
```bash
|
||||
/hooks panel
|
||||
```
|
||||
|
||||
Verify the hook appears in the list and is enabled.
|
||||
|
||||
**Verify matcher pattern:**
|
||||
|
||||
```bash
|
||||
# Test regex pattern
|
||||
echo "write_file|replace" | grep -E "write_.*|replace"
|
||||
```
|
||||
|
||||
**Check disabled list:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"disabled": ["my-hook-name"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Ensure script is executable:**
|
||||
|
||||
```bash
|
||||
ls -la .gemini/hooks/my-hook.sh
|
||||
chmod +x .gemini/hooks/my-hook.sh
|
||||
```
|
||||
|
||||
**Verify script path:**
|
||||
|
||||
```bash
|
||||
# Check path expansion
|
||||
echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh"
|
||||
|
||||
# Verify file exists
|
||||
test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists"
|
||||
```
|
||||
|
||||
### Hook timing out
|
||||
|
||||
**Check configured timeout:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "slow-hook",
|
||||
"timeout": 60000
|
||||
}
|
||||
```
|
||||
|
||||
**Optimize slow operations:**
|
||||
|
||||
```javascript
|
||||
// Before: Sequential operations (slow)
|
||||
for (const item of items) {
|
||||
await processItem(item);
|
||||
}
|
||||
|
||||
// After: Parallel operations (fast)
|
||||
await Promise.all(items.map((item) => processItem(item)));
|
||||
```
|
||||
|
||||
**Use caching:**
|
||||
|
||||
```javascript
|
||||
const cache = new Map();
|
||||
|
||||
async function getCachedData(key) {
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
const data = await fetchData(key);
|
||||
cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Consider splitting into multiple faster hooks:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "write_file",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "quick-check",
|
||||
"command": "./quick-validation.sh",
|
||||
"timeout": 1000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "write_file",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "deep-check",
|
||||
"command": "./deep-analysis.sh",
|
||||
"timeout": 30000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid JSON output
|
||||
|
||||
**Validate JSON before outputting:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
output='{"decision": "allow"}'
|
||||
|
||||
# Validate JSON
|
||||
if echo "$output" | jq empty 2>/dev/null; then
|
||||
echo "$output"
|
||||
else
|
||||
echo "Invalid JSON generated" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**Ensure proper quoting and escaping:**
|
||||
|
||||
```javascript
|
||||
// Bad: Unescaped string interpolation
|
||||
const message = `User said: ${userInput}`;
|
||||
console.log(JSON.stringify({ message }));
|
||||
|
||||
// Good: Automatic escaping
|
||||
console.log(JSON.stringify({ message: `User said: ${userInput}` }));
|
||||
```
|
||||
|
||||
**Check for binary data or control characters:**
|
||||
|
||||
```javascript
|
||||
function sanitizeForJSON(str) {
|
||||
return str.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); // Remove control chars
|
||||
}
|
||||
|
||||
const cleanContent = sanitizeForJSON(content);
|
||||
console.log(JSON.stringify({ content: cleanContent }));
|
||||
```
|
||||
|
||||
### Exit code issues
|
||||
|
||||
**Verify script returns correct codes:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -e # Exit on error
|
||||
|
||||
# Processing logic
|
||||
if validate_input; then
|
||||
echo "Success"
|
||||
exit 0
|
||||
else
|
||||
echo "Validation failed" >&2
|
||||
exit 2
|
||||
fi
|
||||
```
|
||||
|
||||
**Check for unintended errors:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Don't use 'set -e' if you want to handle errors explicitly
|
||||
# set -e
|
||||
|
||||
if ! command_that_might_fail; then
|
||||
# Handle error
|
||||
echo "Command failed but continuing" >&2
|
||||
fi
|
||||
|
||||
# Always exit explicitly
|
||||
exit 0
|
||||
```
|
||||
|
||||
**Use trap for cleanup:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cleanup() {
|
||||
# Cleanup logic
|
||||
rm -f /tmp/hook-temp-*
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# Hook logic here
|
||||
```
|
||||
|
||||
### Environment variables not available
|
||||
|
||||
**Check if variable is set:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [ -z "$GEMINI_PROJECT_DIR" ]; then
|
||||
echo "GEMINI_PROJECT_DIR not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$CUSTOM_VAR" ]; then
|
||||
echo "Warning: CUSTOM_VAR not set, using default" >&2
|
||||
CUSTOM_VAR="default-value"
|
||||
fi
|
||||
```
|
||||
|
||||
**Debug available variables:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# List all environment variables
|
||||
env > .gemini/hook-env.log
|
||||
|
||||
# Check specific variables
|
||||
echo "GEMINI_PROJECT_DIR: $GEMINI_PROJECT_DIR" >> .gemini/hook-env.log
|
||||
echo "GEMINI_SESSION_ID: $GEMINI_SESSION_ID" >> .gemini/hook-env.log
|
||||
echo "GEMINI_API_KEY: ${GEMINI_API_KEY:+<set>}" >> .gemini/hook-env.log
|
||||
```
|
||||
|
||||
**Use .env files:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Load .env file if it exists
|
||||
if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then
|
||||
source "$GEMINI_PROJECT_DIR/.env"
|
||||
fi
|
||||
```
|
||||
|
||||
## Using Hooks Securely
|
||||
## Hook security
|
||||
|
||||
### Threat Model
|
||||
|
||||
@@ -621,11 +392,10 @@ When you open a project with hooks defined in `.gemini/settings.json`:
|
||||
it).
|
||||
5. **Trust**: The hook is marked as "trusted" for this project.
|
||||
|
||||
> [!IMPORTANT] **Modification Detection**: If the `command` string of a project
|
||||
> hook is changed (e.g., by a `git pull`), its identity changes. Gemini CLI will
|
||||
> treat it as a **new, untrusted hook** and warn you again. This prevents
|
||||
> malicious actors from silently swapping a verified command for a malicious
|
||||
> one.
|
||||
> **Modification detection**: If the `command` string of a project hook is
|
||||
> changed (e.g., by a `git pull`), its identity changes. Gemini CLI will treat
|
||||
> it as a **new, untrusted hook** and warn you again. This prevents malicious
|
||||
> actors from silently swapping a verified command for a malicious one.
|
||||
|
||||
### Risks
|
||||
|
||||
@@ -646,32 +416,134 @@ When you open a project with hooks defined in `.gemini/settings.json`:
|
||||
publishers, well-known community members).
|
||||
- Be cautious with obfuscated scripts or compiled binaries from unknown sources.
|
||||
|
||||
#### Sanitize Environment
|
||||
#### Sanitize environment
|
||||
|
||||
Hooks inherit the environment of the Gemini CLI process, which may include
|
||||
sensitive API keys. Gemini CLI attempts to sanitize sensitive variables, but you
|
||||
should be cautious.
|
||||
sensitive API keys. Gemini CLI provides a
|
||||
[redaction system](/docs/get-started/configuration#environment-variable-redaction)
|
||||
that automatically filters variables matching sensitive patterns (e.g., `KEY`,
|
||||
`TOKEN`).
|
||||
|
||||
- **Avoid printing environment variables** to stdout/stderr unless necessary.
|
||||
- **Use `.env` files** to securely manage sensitive variables, ensuring they are
|
||||
excluded from version control.
|
||||
> **Disabled by Default**: Environment redaction is currently **OFF by
|
||||
> default**. We strongly recommend enabling it if you are running third-party
|
||||
> hooks or working in sensitive environments.
|
||||
|
||||
**System Administrators:** You can enforce environment variable redaction by
|
||||
default in the system configuration (e.g., `/etc/gemini-cli/settings.json`):
|
||||
**Impact on hooks:**
|
||||
|
||||
- **Security**: Prevents your hook scripts from accidentally leaking secrets.
|
||||
- **Troubleshooting**: If your hook depends on a specific environment variable
|
||||
that is being blocked, you must explicitly allow it in `settings.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"environmentVariableRedaction": {
|
||||
"enabled": true,
|
||||
"blocked": ["MY_SECRET_KEY"],
|
||||
"allowed": ["SAFE_VAR"]
|
||||
"allowed": ["MY_REQUIRED_TOOL_KEY"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authoring Secure Hooks
|
||||
**System administrators:** You can enforce redaction for all users in the system
|
||||
configuration.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook not executing
|
||||
|
||||
**Check hook name in `/hooks panel`:** Verify the hook appears in the list and
|
||||
is enabled.
|
||||
|
||||
**Verify matcher pattern:**
|
||||
|
||||
```bash
|
||||
# Test regex pattern
|
||||
echo "write_file|replace" | grep -E "write_.*|replace"
|
||||
|
||||
```
|
||||
|
||||
**Check disabled list:** Verify the hook is not listed in your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"disabled": ["my-hook-name"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Ensure script is executable**: For macOS and Linux users, verify the script
|
||||
has execution permissions:
|
||||
|
||||
```bash
|
||||
ls -la .gemini/hooks/my-hook.sh
|
||||
chmod +x .gemini/hooks/my-hook.sh
|
||||
```
|
||||
|
||||
**Verify script path:** Ensure the path in `settings.json` resolves correctly.
|
||||
|
||||
```bash
|
||||
# Check path expansion
|
||||
echo "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh"
|
||||
|
||||
# Verify file exists
|
||||
test -f "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh" && echo "File exists"
|
||||
```
|
||||
|
||||
### Hook timing out
|
||||
|
||||
**Check configured timeout:** The default is 60000ms (1 minute). You can
|
||||
increase this in `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "slow-hook",
|
||||
"timeout": 120000
|
||||
}
|
||||
```
|
||||
|
||||
**Optimize slow operations:** Move heavy processing to background tasks or use
|
||||
caching.
|
||||
|
||||
### Invalid JSON output
|
||||
|
||||
**Validate JSON before outputting:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
output='{"decision": "allow"}'
|
||||
|
||||
# Validate JSON
|
||||
if echo "$output" | jq empty 2>/dev/null; then
|
||||
echo "$output"
|
||||
else
|
||||
echo "Invalid JSON generated" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
```
|
||||
|
||||
### Environment variables not available
|
||||
|
||||
**Check if variable is set:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
if [ -z "$GEMINI_PROJECT_DIR" ]; then
|
||||
echo "GEMINI_PROJECT_DIR not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
```
|
||||
|
||||
**Debug available variables:**
|
||||
|
||||
```bash
|
||||
env > .gemini/hook-env.log
|
||||
```
|
||||
|
||||
## Authoring secure hooks
|
||||
|
||||
When writing your own hooks, follow these practices to ensure they are robust
|
||||
and secure.
|
||||
@@ -713,6 +585,7 @@ defaults to 60 seconds, but you should set stricter limits for fast hooks.
|
||||
"hooks": [
|
||||
{
|
||||
"name": "fast-validator",
|
||||
"type": "command",
|
||||
"command": "./hooks/validate.sh",
|
||||
"timeout": 5000 // 5 seconds
|
||||
}
|
||||
@@ -766,40 +639,17 @@ function containsSecret(content) {
|
||||
|
||||
## Privacy considerations
|
||||
|
||||
Hook inputs and outputs may contain sensitive information. Gemini CLI respects
|
||||
the `telemetry.logPrompts` setting for hook data logging.
|
||||
Hook inputs and outputs may contain sensitive information.
|
||||
|
||||
### What data is collected
|
||||
|
||||
Hook telemetry may include:
|
||||
|
||||
- **Hook inputs:** User prompts, tool arguments, file contents
|
||||
- **Hook outputs:** Hook responses, decision reasons, added context
|
||||
- **Standard streams:** stdout and stderr from hook processes
|
||||
- **Execution metadata:** Hook name, event type, duration, success/failure
|
||||
Hook telemetry may include inputs (prompts, code) and outputs (decisions,
|
||||
reasons) unless disabled.
|
||||
|
||||
### Privacy settings
|
||||
|
||||
**Enabled (default):**
|
||||
|
||||
Full hook I/O is logged to telemetry. Use this when:
|
||||
|
||||
- Developing and debugging hooks
|
||||
- Telemetry is redirected to a trusted enterprise system
|
||||
- You understand and accept the privacy implications
|
||||
|
||||
**Disabled:**
|
||||
|
||||
Only metadata is logged (event name, duration, success/failure). Hook inputs and
|
||||
outputs are excluded. Use this when:
|
||||
|
||||
- Sending telemetry to third-party systems
|
||||
- Working with sensitive data
|
||||
- Privacy regulations require minimizing data collection
|
||||
|
||||
### Configuration
|
||||
|
||||
**Disable PII logging in settings:**
|
||||
**Disable PII logging:** If you are working with sensitive data, disable prompt
|
||||
logging in your settings:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -809,48 +659,19 @@ outputs are excluded. Use this when:
|
||||
}
|
||||
```
|
||||
|
||||
**Disable via environment variable:**
|
||||
**Suppress Output:** Individual hooks can request their metadata be hidden from
|
||||
logs and telemetry by returning `"suppressOutput": true` in their JSON response.
|
||||
|
||||
```bash
|
||||
export GEMINI_TELEMETRY_LOG_PROMPTS=false
|
||||
```
|
||||
> **Note**
|
||||
|
||||
> `suppressOutput` only affects background logging. Any `systemMessage` or
|
||||
> `reason` included in the JSON will still be displayed to the user in the
|
||||
> terminal.
|
||||
|
||||
### Sensitive data in hooks
|
||||
|
||||
If your hooks process sensitive data:
|
||||
|
||||
1. **Minimize logging:** Don't write sensitive data to log files
|
||||
2. **Sanitize outputs:** Remove sensitive data before outputting
|
||||
3. **Use secure storage:** Encrypt sensitive data at rest
|
||||
4. **Limit access:** Restrict hook script permissions
|
||||
|
||||
**Example sanitization:**
|
||||
|
||||
```javascript
|
||||
function sanitizeOutput(data) {
|
||||
const sanitized = { ...data };
|
||||
|
||||
// Remove sensitive fields
|
||||
delete sanitized.apiKey;
|
||||
delete sanitized.password;
|
||||
|
||||
// Redact sensitive strings
|
||||
if (sanitized.content) {
|
||||
sanitized.content = sanitized.content.replace(
|
||||
/api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi,
|
||||
'[REDACTED]',
|
||||
);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(sanitizeOutput(hookOutput)));
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
- [Hooks Reference](index.md) - Complete API reference
|
||||
- [Writing Hooks](writing-hooks.md) - Tutorial and examples
|
||||
- [Configuration](../get-started/configuration.md) - Gemini CLI settings
|
||||
- [Hooks Design Document](../hooks-design.md) - Technical architecture
|
||||
1. **Minimize logging:** Don't write sensitive data to log files.
|
||||
2. **Sanitize outputs:** Remove sensitive data before outputting JSON or writing
|
||||
to stderr.
|
||||
|
||||
+112
-671
@@ -1,112 +1,104 @@
|
||||
# Gemini CLI hooks (experimental)
|
||||
# Gemini CLI hooks
|
||||
|
||||
Hooks are scripts or programs that Gemini CLI executes at specific points in the
|
||||
agentic loop, allowing you to intercept and customize behavior without modifying
|
||||
the CLI's source code.
|
||||
|
||||
> [!NOTE] **Hooks are currently an experimental feature.**
|
||||
>
|
||||
> To use hooks, you must explicitly enable them in your `settings.json`:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "tools": { "enableHooks": true },
|
||||
> "hooks": { "enabled": true }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Both of these are needed in this experimental phase.
|
||||
|
||||
See [writing hooks guide](writing-hooks.md) for a tutorial on creating your
|
||||
first hook and a comprehensive example.
|
||||
|
||||
See [hooks reference](reference.md) for the technical specification of the I/O
|
||||
schemas.
|
||||
|
||||
See [best practices](best-practices.md) for guidelines on security, performance,
|
||||
and debugging.
|
||||
|
||||
## What are hooks?
|
||||
|
||||
With hooks, you can:
|
||||
|
||||
- **Add context:** Inject relevant information before the model processes a
|
||||
request
|
||||
- **Validate actions:** Review and block potentially dangerous operations
|
||||
- **Enforce policies:** Implement security and compliance requirements
|
||||
- **Log interactions:** Track tool usage and model responses
|
||||
- **Optimize behavior:** Dynamically adjust tool selection or model parameters
|
||||
|
||||
Hooks run synchronously as part of the agent loop—when a hook event fires,
|
||||
Gemini CLI waits for all matching hooks to complete before continuing.
|
||||
|
||||
## Security and Risks
|
||||
With hooks, you can:
|
||||
|
||||
> **Warning: Hooks execute arbitrary code with your user privileges.**
|
||||
>
|
||||
> By configuring hooks, you are explicitly allowing Gemini CLI to run shell
|
||||
> commands on your machine. Malicious or poorly configured hooks can:
|
||||
- **Add context:** Inject relevant information (like git history) before the
|
||||
model processes a request.
|
||||
- **Validate actions:** Review tool arguments and block potentially dangerous
|
||||
operations.
|
||||
- **Enforce policies:** Implement security scanners and compliance checks.
|
||||
- **Log interactions:** Track tool usage and model responses for auditing.
|
||||
- **Optimize behavior:** Dynamically filter available tools or adjust model
|
||||
parameters.
|
||||
|
||||
- **Exfiltrate data**: Read sensitive files (`.env`, ssh keys) and send them to
|
||||
remote servers.
|
||||
- **Modify system**: Delete files, install malware, or change system settings.
|
||||
- **Consume resources**: Run infinite loops or crash your system.
|
||||
### Getting started
|
||||
|
||||
**Project-level hooks** (in `.gemini/settings.json`) and **Extension hooks** are
|
||||
particularly risky when opening third-party projects or extensions from
|
||||
untrusted authors. Gemini CLI will **warn you** the first time it detects a new
|
||||
project hook (identified by its name and command), but it is **your
|
||||
responsibility** to review these hooks (and any installed extensions) before
|
||||
trusting them.
|
||||
|
||||
> **Note:** Extension hooks are subject to a mandatory security warning and
|
||||
> consent flow during extension installation or update if hooks are detected.
|
||||
> You must explicitly approve the installation or update of any extension that
|
||||
> contains hooks.
|
||||
|
||||
See [Security Considerations](best-practices.md#using-hooks-securely) for a
|
||||
detailed threat model and mitigation strategies.
|
||||
- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating
|
||||
your first hook with comprehensive examples.
|
||||
- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security,
|
||||
performance, and debugging.
|
||||
- **[Hooks reference](/docs/hooks/reference)**: The definitive technical
|
||||
specification of I/O schemas and exit codes.
|
||||
|
||||
## Core concepts
|
||||
|
||||
### Hook events
|
||||
|
||||
Hooks are triggered by specific events in Gemini CLI's lifecycle. The following
|
||||
table lists all available hook events:
|
||||
Hooks are triggered by specific events in Gemini CLI's lifecycle.
|
||||
|
||||
| Event | When It Fires | Common Use Cases |
|
||||
| --------------------- | --------------------------------------------- | ------------------------------------------ |
|
||||
| `SessionStart` | When a session begins | Initialize resources, load context |
|
||||
| `SessionEnd` | When a session ends | Clean up, save state |
|
||||
| `BeforeAgent` | After user submits prompt, before planning | Add context, validate prompts |
|
||||
| `AfterAgent` | When agent loop ends | Review output, force continuation |
|
||||
| `BeforeModel` | Before sending request to LLM | Modify prompts, add instructions |
|
||||
| `AfterModel` | After receiving LLM response | Filter responses, log interactions |
|
||||
| `BeforeToolSelection` | Before LLM selects tools (after BeforeModel) | Filter available tools, optimize selection |
|
||||
| `BeforeTool` | Before a tool executes | Validate arguments, block dangerous ops |
|
||||
| `AfterTool` | After a tool executes | Process results, run tests |
|
||||
| `PreCompress` | Before context compression | Save state, notify user |
|
||||
| `Notification` | When a notification occurs (e.g., permission) | Auto-approve, log decisions |
|
||||
| Event | When It Fires | Impact | Common Use Cases |
|
||||
| --------------------- | ---------------------------------------------- | ---------------------- | -------------------------------------------- |
|
||||
| `SessionStart` | When a session begins (startup, resume, clear) | Inject Context | Initialize resources, load context |
|
||||
| `SessionEnd` | When a session ends (exit, clear) | Advisory | Clean up, save state |
|
||||
| `BeforeAgent` | After user submits prompt, before planning | Block Turn / Context | Add context, validate prompts, block turns |
|
||||
| `AfterAgent` | When agent loop ends | Retry / Halt | Review output, force retry or halt execution |
|
||||
| `BeforeModel` | Before sending request to LLM | Block Turn / Mock | Modify prompts, swap models, mock responses |
|
||||
| `AfterModel` | After receiving LLM response | Block Turn / Redact | Filter/redact responses, log interactions |
|
||||
| `BeforeToolSelection` | Before LLM selects tools | Filter Tools | Filter available tools, optimize selection |
|
||||
| `BeforeTool` | Before a tool executes | Block Tool / Rewrite | Validate arguments, block dangerous ops |
|
||||
| `AfterTool` | After a tool executes | Block Result / Context | Process results, run tests, hide results |
|
||||
| `PreCompress` | Before context compression | Advisory | Save state, notify user |
|
||||
| `Notification` | When a system notification occurs | Advisory | Forward to desktop alerts, logging |
|
||||
|
||||
### Hook types
|
||||
### Global mechanics
|
||||
|
||||
Gemini CLI currently supports **command hooks** that run shell commands or
|
||||
scripts:
|
||||
Understanding these core principles is essential for building robust hooks.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/my-hook.sh",
|
||||
"timeout": 30000
|
||||
}
|
||||
```
|
||||
#### Strict JSON requirements (The "Golden Rule")
|
||||
|
||||
**Note:** Plugin hooks (npm packages) are planned for a future release.
|
||||
Hooks communicate via `stdin` (Input) and `stdout` (Output).
|
||||
|
||||
### Matchers
|
||||
1. **Silence is Mandatory**: Your script **must not** print any plain text to
|
||||
`stdout` other than the final JSON object. **Even a single `echo` or `print`
|
||||
call before the JSON will break parsing.**
|
||||
2. **Pollution = Failure**: If `stdout` contains non-JSON text, parsing will
|
||||
fail. The CLI will default to "Allow" and treat the entire output as a
|
||||
`systemMessage`.
|
||||
3. **Debug via Stderr**: Use `stderr` for **all** logging and debugging (e.g.,
|
||||
`echo "debug" >&2`). Gemini CLI captures `stderr` but never attempts to parse
|
||||
it as JSON.
|
||||
|
||||
For tool-related events (`BeforeTool`, `AfterTool`), you can filter which tools
|
||||
trigger the hook:
|
||||
#### Exit codes
|
||||
|
||||
Gemini CLI uses exit codes to determine the high-level outcome of a hook
|
||||
execution:
|
||||
|
||||
| Exit Code | Label | Behavioral Impact |
|
||||
| --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **0** | **Success** | The `stdout` is parsed as JSON. **Preferred code** for all logic, including intentional blocks (e.g., `{"decision": "deny"}`). |
|
||||
| **2** | **System Block** | **Critical Block**. The target action (tool, turn, or stop) is aborted. `stderr` is used as the rejection reason. High severity; used for security stops or script failures. |
|
||||
| **Other** | **Warning** | Non-fatal failure. A warning is shown, but the interaction proceeds using original parameters. |
|
||||
|
||||
#### Matchers
|
||||
|
||||
You can filter which specific tools or triggers fire your hook using the
|
||||
`matcher` field.
|
||||
|
||||
- **Tool events** (`BeforeTool`, `AfterTool`): Matchers are **Regular
|
||||
Expressions**. (e.g., `"write_.*"`).
|
||||
- **Lifecycle events**: Matchers are **Exact Strings**. (e.g., `"startup"`).
|
||||
- **Wildcards**: `"*"` or `""` (empty string) matches all occurrences.
|
||||
|
||||
## Configuration
|
||||
|
||||
Hooks are configured in `settings.json`. Gemini CLI merges configurations from
|
||||
multiple layers in the following order of precedence (highest to lowest):
|
||||
|
||||
1. **Project settings**: `.gemini/settings.json` in the current directory.
|
||||
2. **User settings**: `~/.gemini/settings.json`.
|
||||
3. **System settings**: `/etc/gemini-cli/settings.json`.
|
||||
4. **Extensions**: Hooks defined by installed extensions.
|
||||
|
||||
### Configuration schema
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -114,382 +106,12 @@ trigger the hook:
|
||||
"BeforeTool": [
|
||||
{
|
||||
"matcher": "write_file|replace",
|
||||
"hooks": [
|
||||
/* hooks for write operations */
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Matcher patterns:**
|
||||
|
||||
- **Exact match:** `"read_file"` matches only `read_file`
|
||||
- **Regex:** `"write_.*|replace"` matches `write_file`, `replace`
|
||||
- **Wildcard:** `"*"` or `""` matches all tools
|
||||
|
||||
**Session event matchers:**
|
||||
|
||||
- **SessionStart:** `startup`, `resume`, `clear`
|
||||
- **SessionEnd:** `exit`, `clear`, `logout`, `prompt_input_exit`
|
||||
- **PreCompress:** `manual`, `auto`
|
||||
- **Notification:** `ToolPermission`
|
||||
|
||||
## Hook input/output contract
|
||||
|
||||
### Command hook communication
|
||||
|
||||
Hooks communicate via:
|
||||
|
||||
- **Input:** JSON on stdin
|
||||
- **Output:** Exit code + stdout/stderr
|
||||
|
||||
### Exit codes
|
||||
|
||||
- **0:** Success - stdout shown to user (or injected as context for some events)
|
||||
- **2:** Blocking error - stderr shown to agent/user, operation may be blocked
|
||||
- **Other:** Non-blocking warning - logged but execution continues
|
||||
|
||||
### Common input fields
|
||||
|
||||
Every hook receives these base fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "abc123",
|
||||
"transcript_path": "/path/to/transcript.jsonl",
|
||||
"cwd": "/path/to/project",
|
||||
"hook_event_name": "BeforeTool",
|
||||
"timestamp": "2025-12-01T10:30:00Z"
|
||||
// ... event-specific fields
|
||||
}
|
||||
```
|
||||
|
||||
### Event-specific fields
|
||||
|
||||
#### BeforeTool
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "write_file",
|
||||
"tool_input": {
|
||||
"file_path": "/path/to/file.ts",
|
||||
"content": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output (JSON on stdout):**
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "allow|deny|ask|block",
|
||||
"reason": "Explanation shown to agent",
|
||||
"systemMessage": "Message shown to user"
|
||||
}
|
||||
```
|
||||
|
||||
Or simple exit codes:
|
||||
|
||||
- Exit 0 = allow (stdout shown to user)
|
||||
- Exit 2 = deny (stderr shown to agent)
|
||||
|
||||
#### AfterTool
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "read_file",
|
||||
"tool_input": { "file_path": "..." },
|
||||
"tool_response": "file contents..."
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "allow|deny",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "AfterTool",
|
||||
"additionalContext": "Extra context for agent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### BeforeAgent
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "Fix the authentication bug"
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "allow|deny",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "BeforeAgent",
|
||||
"additionalContext": "Recent project decisions: ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### BeforeModel
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"llm_request": {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"messages": [{ "role": "user", "content": "Hello" }],
|
||||
"config": { "temperature": 0.7 },
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "AUTO",
|
||||
"allowedFunctionNames": ["read_file", "write_file"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "allow",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "BeforeModel",
|
||||
"llm_request": {
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Additional instructions..." },
|
||||
{ "role": "user", "content": "Hello" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### AfterModel
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"llm_request": {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"messages": [
|
||||
/* ... */
|
||||
],
|
||||
"config": {
|
||||
/* ... */
|
||||
},
|
||||
"toolConfig": {
|
||||
/* ... */
|
||||
}
|
||||
},
|
||||
"llm_response": {
|
||||
"text": "string",
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": ["array of content parts"]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "AfterModel",
|
||||
"llm_response": {
|
||||
"candidate": {
|
||||
/* modified response */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### BeforeToolSelection
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"llm_request": {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"messages": [
|
||||
/* ... */
|
||||
],
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "AUTO",
|
||||
"allowedFunctionNames": [
|
||||
/* 100+ tools */
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "BeforeToolSelection",
|
||||
"toolConfig": {
|
||||
"functionCallingConfig": {
|
||||
"mode": "ANY",
|
||||
"allowedFunctionNames": ["read_file", "write_file", "replace"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or simple output (comma-separated tool names sets mode to ANY):
|
||||
|
||||
```bash
|
||||
echo "read_file,write_file,replace"
|
||||
```
|
||||
|
||||
#### SessionStart
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "startup|resume|clear"
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": "Loaded 5 project memories"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### SessionEnd
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "exit|clear|logout|prompt_input_exit|other"
|
||||
}
|
||||
```
|
||||
|
||||
No structured output expected (but stdout/stderr logged).
|
||||
|
||||
#### PreCompress
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger": "manual|auto"
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"systemMessage": "Compression starting..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Notification
|
||||
|
||||
**Input:**
|
||||
|
||||
```json
|
||||
{
|
||||
"notification_type": "ToolPermission",
|
||||
"message": "string",
|
||||
"details": {
|
||||
/* notification details */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
"systemMessage": "Notification logged"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Hook definitions are configured in `settings.json` files using the `hooks`
|
||||
object. Configuration can be specified at multiple levels with defined
|
||||
precedence rules.
|
||||
|
||||
### Configuration layers
|
||||
|
||||
Hook configurations are applied in the following order of execution (lower
|
||||
numbers run first):
|
||||
|
||||
1. **Project settings:** `.gemini/settings.json` in your project directory
|
||||
(highest priority)
|
||||
2. **User settings:** `~/.gemini/settings.json`
|
||||
3. **System settings:** `/etc/gemini-cli/settings.json`
|
||||
4. **Extensions:** Internal hooks defined by installed extensions (lowest
|
||||
priority). See [Extensions documentation](../extensions/index.md#hooks) for
|
||||
details on how extensions define and configure hooks.
|
||||
|
||||
#### Deduplication and shadowing
|
||||
|
||||
If multiple hooks with the identical **name** and **command** are discovered
|
||||
across different configuration layers, Gemini CLI deduplicates them. The hook
|
||||
from the higher-priority layer (e.g., Project) will be kept, and others will be
|
||||
ignored.
|
||||
|
||||
Within each level, hooks run in the order they are declared in the
|
||||
configuration.
|
||||
|
||||
### Configuration schema
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"EventName": [
|
||||
{
|
||||
"matcher": "pattern",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "hook-identifier",
|
||||
"name": "security-check",
|
||||
"type": "command",
|
||||
"command": "./path/to/script.sh",
|
||||
"description": "What this hook does",
|
||||
"timeout": 30000
|
||||
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/security.sh",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -498,226 +120,45 @@ configuration.
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration properties:**
|
||||
#### Hook configuration fields
|
||||
|
||||
- **`name`** (string, recommended): Unique identifier for the hook used in
|
||||
`/hooks enable/disable` commands. If omitted, the `command` path is used as
|
||||
the identifier.
|
||||
- **`type`** (string, required): Hook type - currently only `"command"` is
|
||||
supported
|
||||
- **`command`** (string, required): Path to the script or command to execute
|
||||
- **`description`** (string, optional): Human-readable description shown in
|
||||
`/hooks panel`
|
||||
- **`timeout`** (number, optional): Timeout in milliseconds (default: 60000)
|
||||
- **`matcher`** (string, optional): Pattern to filter when hook runs (event
|
||||
matchers only)
|
||||
| Field | Type | Required | Description |
|
||||
| :------------ | :----- | :-------- | :------------------------------------------------------------------- |
|
||||
| `type` | string | **Yes** | The execution engine. Currently only `"command"` is supported. |
|
||||
| `command` | string | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). |
|
||||
| `name` | string | No | A friendly name for identifying the hook in logs and CLI commands. |
|
||||
| `timeout` | number | No | Execution timeout in milliseconds (default: 60000). |
|
||||
| `description` | string | No | A brief explanation of the hook's purpose. |
|
||||
|
||||
---
|
||||
|
||||
### Environment variables
|
||||
|
||||
Hooks have access to:
|
||||
Hooks are executed with a sanitized environment.
|
||||
|
||||
- `GEMINI_PROJECT_DIR`: Project root directory
|
||||
- `GEMINI_SESSION_ID`: Current session ID
|
||||
- `GEMINI_API_KEY`: Gemini API key (if configured)
|
||||
- All other environment variables from the parent process
|
||||
- `GEMINI_PROJECT_DIR`: The absolute path to the project root.
|
||||
- `GEMINI_SESSION_ID`: The unique ID for the current session.
|
||||
- `GEMINI_CWD`: The current working directory.
|
||||
- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility.
|
||||
|
||||
## Security and risks
|
||||
|
||||
> **Warning: Hooks execute arbitrary code with your user privileges.** By
|
||||
> configuring hooks, you are allowing scripts to run shell commands on your
|
||||
> machine.
|
||||
|
||||
**Project-level hooks** are particularly risky when opening untrusted projects.
|
||||
Gemini CLI **fingerprints** project hooks. If a hook's name or command changes
|
||||
(e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will
|
||||
be warned before it executes.
|
||||
|
||||
See [Security Considerations](/docs/hooks/best-practices#using-hooks-securely)
|
||||
for a detailed threat model.
|
||||
|
||||
## Managing hooks
|
||||
|
||||
### View registered hooks
|
||||
Use the CLI commands to manage hooks without editing JSON manually:
|
||||
|
||||
Use the `/hooks panel` command to view all registered hooks:
|
||||
|
||||
```bash
|
||||
/hooks panel
|
||||
```
|
||||
|
||||
This command displays:
|
||||
|
||||
- All configured hooks organized by event
|
||||
- Hook source (user, project, system)
|
||||
- Hook type (command or plugin)
|
||||
- Individual hook status (enabled/disabled)
|
||||
|
||||
### Enable and disable all hooks at once
|
||||
|
||||
You can enable or disable all hooks at once using commands:
|
||||
|
||||
```bash
|
||||
/hooks enable-all
|
||||
/hooks disable-all
|
||||
```
|
||||
|
||||
These commands provide a shortcut to enable or disable all configured hooks
|
||||
without managing them individually. The `enable-all` command removes all hooks
|
||||
from the `hooks.disabled` array, while `disable-all` adds all configured hooks
|
||||
to the disabled list. Changes take effect immediately without requiring a
|
||||
restart.
|
||||
|
||||
### Enable and disable individual hooks
|
||||
|
||||
You can enable or disable individual hooks using commands:
|
||||
|
||||
```bash
|
||||
/hooks enable hook-name
|
||||
/hooks disable hook-name
|
||||
```
|
||||
|
||||
These commands allow you to control hook execution without editing configuration
|
||||
files. The hook name should match the `name` field in your hook configuration.
|
||||
Changes made via these commands are persisted to your settings. The settings are
|
||||
saved to workspace scope if available, otherwise to your global user settings
|
||||
(`~/.gemini/settings.json`).
|
||||
|
||||
### Disabled hooks configuration
|
||||
|
||||
To permanently disable hooks, add them to the `hooks.disabled` array in your
|
||||
`settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"disabled": ["secret-scanner", "auto-test"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The `hooks.disabled` array uses a UNION merge strategy. Disabled hooks
|
||||
from all configuration levels (user, project, system) are combined and
|
||||
deduplicated, meaning a hook disabled at any level remains disabled.
|
||||
|
||||
## Migration from Claude Code
|
||||
|
||||
If you have hooks configured for Claude Code, you can migrate them:
|
||||
|
||||
```bash
|
||||
gemini hooks migrate --from-claude
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
- Reads `.claude/settings.json`
|
||||
- Converts event names (`PreToolUse` → `BeforeTool`, etc.)
|
||||
- Translates tool names (`Bash` → `run_shell_command`, `replace` → `replace`)
|
||||
- Updates matcher patterns
|
||||
- Writes to `.gemini/settings.json`
|
||||
|
||||
### Event name mapping
|
||||
|
||||
| Claude Code | Gemini CLI |
|
||||
| ------------------ | -------------- |
|
||||
| `PreToolUse` | `BeforeTool` |
|
||||
| `PostToolUse` | `AfterTool` |
|
||||
| `UserPromptSubmit` | `BeforeAgent` |
|
||||
| `Stop` | `AfterAgent` |
|
||||
| `Notification` | `Notification` |
|
||||
| `SessionStart` | `SessionStart` |
|
||||
| `SessionEnd` | `SessionEnd` |
|
||||
| `PreCompact` | `PreCompress` |
|
||||
|
||||
### Tool name mapping
|
||||
|
||||
| Claude Code | Gemini CLI |
|
||||
| ----------- | --------------------- |
|
||||
| `Bash` | `run_shell_command` |
|
||||
| `Edit` | `replace` |
|
||||
| `Read` | `read_file` |
|
||||
| `Write` | `write_file` |
|
||||
| `Glob` | `glob` |
|
||||
| `Grep` | `search_file_content` |
|
||||
| `LS` | `list_directory` |
|
||||
|
||||
## Tool and Event Matchers Reference
|
||||
|
||||
### Available tool names for matchers
|
||||
|
||||
The following built-in tools can be used in `BeforeTool` and `AfterTool` hook
|
||||
matchers:
|
||||
|
||||
#### File operations
|
||||
|
||||
- `read_file` - Read a single file
|
||||
- `read_many_files` - Read multiple files at once
|
||||
- `write_file` - Create or overwrite a file
|
||||
- `replace` - Edit file content with find/replace
|
||||
|
||||
#### File system
|
||||
|
||||
- `list_directory` - List directory contents
|
||||
- `glob` - Find files matching a pattern
|
||||
- `search_file_content` - Search within file contents
|
||||
|
||||
#### Execution
|
||||
|
||||
- `run_shell_command` - Execute shell commands
|
||||
|
||||
#### Web and external
|
||||
|
||||
- `google_web_search` - Google Search with grounding
|
||||
- `web_fetch` - Fetch web page content
|
||||
|
||||
#### Agent features
|
||||
|
||||
- `write_todos` - Manage TODO items
|
||||
- `save_memory` - Save information to memory
|
||||
- `delegate_to_agent` - Delegate tasks to sub-agents
|
||||
|
||||
#### Example matchers
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "write_file|replace" // File editing tools
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "read_.*" // All read operations
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "run_shell_command" // Only shell commands
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"matcher": "*" // All tools
|
||||
}
|
||||
```
|
||||
|
||||
### Event-specific matchers
|
||||
|
||||
#### SessionStart event matchers
|
||||
|
||||
- `startup` - Fresh session start
|
||||
- `resume` - Resuming a previous session
|
||||
- `clear` - Session cleared
|
||||
|
||||
#### SessionEnd event matchers
|
||||
|
||||
- `exit` - Normal exit
|
||||
- `clear` - Session cleared
|
||||
- `logout` - User logged out
|
||||
- `prompt_input_exit` - Exit from prompt input
|
||||
- `other` - Other reasons
|
||||
|
||||
#### PreCompress event matchers
|
||||
|
||||
- `manual` - Manually triggered compression
|
||||
- `auto` - Automatically triggered compression
|
||||
|
||||
#### Notification event matchers
|
||||
|
||||
- `ToolPermission` - Tool permission notifications
|
||||
|
||||
## Learn more
|
||||
|
||||
- [Writing Hooks](writing-hooks.md) - Tutorial and comprehensive example
|
||||
- [Best Practices](best-practices.md) - Security, performance, and debugging
|
||||
- [Custom Commands](../cli/custom-commands.md) - Create reusable prompt
|
||||
shortcuts
|
||||
- [Configuration](../get-started/configuration.md) - Gemini CLI configuration
|
||||
options
|
||||
- [Hooks Design Document](../hooks-design.md) - Technical architecture details
|
||||
- **View hooks:** `/hooks panel`
|
||||
- **Enable/Disable all:** `/hooks enable-all` or `/hooks disable-all`
|
||||
- **Toggle individual:** `/hooks enable <name>` or `/hooks disable <name>`
|
||||
|
||||
+279
-135
@@ -1,178 +1,322 @@
|
||||
# Hooks Reference
|
||||
# Hooks reference
|
||||
|
||||
This document provides the technical specification for Gemini CLI hooks,
|
||||
including the JSON schemas for input and output, exit code behaviors, and the
|
||||
stable model API.
|
||||
including JSON schemas and API details.
|
||||
|
||||
## Communication Protocol
|
||||
## Global hook mechanics
|
||||
|
||||
Hooks communicate with Gemini CLI via standard streams and exit codes:
|
||||
|
||||
- **Input**: Gemini CLI sends a JSON object to the hook's `stdin`.
|
||||
- **Output**: The hook sends a JSON object (or plain text) to `stdout`.
|
||||
- **Exit Codes**: Used to signal success or blocking errors.
|
||||
|
||||
### Exit Code Behavior
|
||||
|
||||
| Exit Code | Meaning | Behavior |
|
||||
| :-------- | :----------------- | :---------------------------------------------------------------------------------------------- |
|
||||
| `0` | **Success** | `stdout` is parsed as JSON. If parsing fails, it's treated as a `systemMessage`. |
|
||||
| `2` | **Blocking Error** | Interrupts the current operation. `stderr` is shown to the agent (for tool events) or the user. |
|
||||
| Other | **Warning** | Execution continues. `stderr` is logged as a non-blocking warning. |
|
||||
- **Communication**: `stdin` for Input (JSON), `stdout` for Output (JSON), and
|
||||
`stderr` for logs and feedback.
|
||||
- **Exit codes**:
|
||||
- `0`: Success. `stdout` is parsed as JSON. **Preferred for all logic.**
|
||||
- `2`: System Block. The action is blocked; `stderr` is used as the rejection
|
||||
reason.
|
||||
- `Other`: Warning. A non-fatal failure occurred; the CLI continues with a
|
||||
warning.
|
||||
- **Silence is Mandatory**: Your script **must not** print any plain text to
|
||||
`stdout` other than the final JSON.
|
||||
|
||||
---
|
||||
|
||||
## Input Schema (`stdin`)
|
||||
## Configuration schema
|
||||
|
||||
Every hook receives a base JSON object. Extra fields are added depending on the
|
||||
specific event.
|
||||
Hooks are defined in `settings.json` within the `hooks` object. Each event
|
||||
(e.g., `BeforeTool`) contains an array of **hook definitions**.
|
||||
|
||||
### Base Fields (All Events)
|
||||
### Hook definition
|
||||
|
||||
| Field | Type | Description |
|
||||
| :---------------- | :------- | :---------------------------------------------------- |
|
||||
| `session_id` | `string` | Unique identifier for the current CLI session. |
|
||||
| `transcript_path` | `string` | Path to the session's JSON transcript (if available). |
|
||||
| `cwd` | `string` | The current working directory. |
|
||||
| `hook_event_name` | `string` | The name of the firing event (e.g., `BeforeTool`). |
|
||||
| `timestamp` | `string` | ISO 8601 timestamp of the event. |
|
||||
| Field | Type | Required | Description |
|
||||
| :----------- | :-------- | :------- | :-------------------------------------------------------------------------------------- |
|
||||
| `matcher` | `string` | No | A regex (for tools) or exact string (for lifecycle) to filter when the hook runs. |
|
||||
| `sequential` | `boolean` | No | If `true`, hooks in this group run one after another. If `false`, they run in parallel. |
|
||||
| `hooks` | `array` | **Yes** | An array of **hook configurations**. |
|
||||
|
||||
### Event-Specific Fields
|
||||
### Hook configuration
|
||||
|
||||
#### Tool Events (`BeforeTool`, `AfterTool`)
|
||||
|
||||
- `tool_name`: (`string`) The internal name of the tool (e.g., `write_file`,
|
||||
`run_shell_command`).
|
||||
- `tool_input`: (`object`) The arguments passed to the tool.
|
||||
- `tool_response`: (`object`, **AfterTool only**) The raw output from the tool
|
||||
execution.
|
||||
- `mcp_context`: (`object`, **optional**) Present only for MCP tool invocations.
|
||||
Contains server identity information:
|
||||
- `server_name`: (`string`) The configured name of the MCP server.
|
||||
- `tool_name`: (`string`) The original tool name from the MCP server.
|
||||
- `command`: (`string`, optional) For stdio transport, the command used to
|
||||
start the server.
|
||||
- `args`: (`string[]`, optional) For stdio transport, the command arguments.
|
||||
- `cwd`: (`string`, optional) For stdio transport, the working directory.
|
||||
- `url`: (`string`, optional) For SSE/HTTP transport, the server URL.
|
||||
- `tcp`: (`string`, optional) For WebSocket transport, the TCP address.
|
||||
|
||||
#### Agent Events (`BeforeAgent`, `AfterAgent`)
|
||||
|
||||
- `prompt`: (`string`) The user's submitted prompt.
|
||||
- `prompt_response`: (`string`, **AfterAgent only**) The final response text
|
||||
from the model.
|
||||
- `stop_hook_active`: (`boolean`, **AfterAgent only**) Indicates if a stop hook
|
||||
is already handling a continuation.
|
||||
|
||||
#### Model Events (`BeforeModel`, `AfterModel`, `BeforeToolSelection`)
|
||||
|
||||
- `llm_request`: (`LLMRequest`) A stable representation of the outgoing request.
|
||||
See [Stable Model API](#stable-model-api).
|
||||
- `llm_response`: (`LLMResponse`, **AfterModel only**) A stable representation
|
||||
of the incoming response.
|
||||
|
||||
#### Session & Notification Events
|
||||
|
||||
- `source`: (`startup` | `resume` | `clear`, **SessionStart only**) The trigger
|
||||
source.
|
||||
- `reason`: (`exit` | `clear` | `logout` | `prompt_input_exit` | `other`,
|
||||
**SessionEnd only**) The reason for session end.
|
||||
- `trigger`: (`manual` | `auto`, **PreCompress only**) What triggered the
|
||||
compression event.
|
||||
- `notification_type`: (`ToolPermission`, **Notification only**) The type of
|
||||
notification being fired.
|
||||
- `message`: (`string`, **Notification only**) The notification message.
|
||||
- `details`: (`object`, **Notification only**) Payload-specific details for the
|
||||
notification.
|
||||
| Field | Type | Required | Description |
|
||||
| :------------ | :------- | :-------- | :------------------------------------------------------------------- |
|
||||
| `type` | `string` | **Yes** | The execution engine. Currently only `"command"` is supported. |
|
||||
| `command` | `string` | **Yes\*** | The shell command to execute. (Required when `type` is `"command"`). |
|
||||
| `name` | `string` | No | A friendly name for identifying the hook in logs and CLI commands. |
|
||||
| `timeout` | `number` | No | Execution timeout in milliseconds (default: 60000). |
|
||||
| `description` | `string` | No | A brief explanation of the hook's purpose. |
|
||||
|
||||
---
|
||||
|
||||
## Output Schema (`stdout`)
|
||||
## Base input schema
|
||||
|
||||
If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON.
|
||||
All hooks receive these common fields via `stdin`:
|
||||
|
||||
### Common Output Fields
|
||||
```typescript
|
||||
{
|
||||
"session_id": string, // Unique ID for the current session
|
||||
"transcript_path": string, // Absolute path to session transcript JSON
|
||||
"cwd": string, // Current working directory
|
||||
"hook_event_name": string, // The firing event (e.g. "BeforeTool")
|
||||
"timestamp": string // ISO 8601 execution time
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| :------------------- | :-------- | :------------------------------------------------------------------------------------- |
|
||||
| `decision` | `string` | One of: `allow`, `deny`, `block`, `ask`, `approve`. |
|
||||
| `reason` | `string` | Explanation shown to the **agent** when a decision is `deny` or `block`. |
|
||||
| `systemMessage` | `string` | Message displayed in Gemini CLI terminal to provide warning or context to the **user** |
|
||||
| `continue` | `boolean` | If `false`, immediately terminates the agent loop for this turn. |
|
||||
| `stopReason` | `string` | Message shown to the user when `continue` is `false`. |
|
||||
| `suppressOutput` | `boolean` | If `true`, the hook execution is hidden from the CLI transcript. |
|
||||
| `hookSpecificOutput` | `object` | Container for event-specific data (see below). |
|
||||
---
|
||||
|
||||
### `hookSpecificOutput` Reference
|
||||
## Common output fields
|
||||
|
||||
| Field | Supported Events | Description |
|
||||
| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||
| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. |
|
||||
| `llm_request` | `BeforeModel` | A `Partial<LLMRequest>` to override parameters of the outgoing call. |
|
||||
| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. |
|
||||
| `llm_response` | `AfterModel` | A `Partial<LLMResponse>` to modify the model's response before the agent sees it. |
|
||||
| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. |
|
||||
Most hooks support these fields in their `stdout` JSON:
|
||||
|
||||
| Field | Type | Description |
|
||||
| :--------------- | :-------- | :----------------------------------------------------------------------------- |
|
||||
| `systemMessage` | `string` | Displayed immediately to the user in the terminal. |
|
||||
| `suppressOutput` | `boolean` | If `true`, hides internal hook metadata from logs/telemetry. |
|
||||
| `continue` | `boolean` | If `false`, stops the entire agent loop immediately. |
|
||||
| `stopReason` | `string` | Displayed to the user when `continue` is `false`. |
|
||||
| `decision` | `string` | `"allow"` or `"deny"` (alias `"block"`). Specific impact depends on the event. |
|
||||
| `reason` | `string` | The feedback/error message provided when a `decision` is `"deny"`. |
|
||||
|
||||
---
|
||||
|
||||
## Tool hooks
|
||||
|
||||
### Matchers and tool names
|
||||
|
||||
For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is
|
||||
compared against the name of the tool being executed.
|
||||
|
||||
- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`,
|
||||
`run_shell_command`). See the [Tools Reference](/docs/tools) for a full list
|
||||
of available tool names.
|
||||
- **MCP Tools**: Tools from MCP servers follow the naming pattern
|
||||
`mcp__<server_name>__<tool_name>`.
|
||||
- **Regex Support**: Matchers support regular expressions (e.g.,
|
||||
`matcher: "read_.*"` matches all file reading tools).
|
||||
|
||||
### `BeforeTool`
|
||||
|
||||
Fires before a tool is invoked. Used for argument validation, security checks,
|
||||
and parameter rewriting.
|
||||
|
||||
- **Input Fields**:
|
||||
- `tool_name`: (`string`) The name of the tool being called.
|
||||
- `tool_input`: (`object`) The raw arguments generated by the model.
|
||||
- `mcp_context`: (`object`) Optional metadata for MCP-based tools.
|
||||
- **Relevant Output Fields**:
|
||||
- `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from
|
||||
executing.
|
||||
- `reason`: Required if denied. This text is sent **to the agent** as a tool
|
||||
error, allowing it to respond or retry.
|
||||
- `hookSpecificOutput.tool_input`: An object that **merges with and
|
||||
overrides** the model's arguments before execution.
|
||||
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||
- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the
|
||||
`reason` sent to the agent. **The turn continues.**
|
||||
|
||||
### `AfterTool`
|
||||
|
||||
Fires after a tool executes. Used for result auditing, context injection, or
|
||||
hiding sensitive output from the agent.
|
||||
|
||||
- **Input Fields**:
|
||||
- `tool_name`: (`string`)
|
||||
- `tool_input`: (`object`) The original arguments.
|
||||
- `tool_response`: (`object`) The result containing `llmContent`,
|
||||
`returnDisplay`, and optional `error`.
|
||||
- `mcp_context`: (`object`)
|
||||
- **Relevant Output Fields**:
|
||||
- `decision`: Set to `"deny"` to hide the real tool output from the agent.
|
||||
- `reason`: Required if denied. This text **replaces** the tool result sent
|
||||
back to the model.
|
||||
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
|
||||
tool result for the agent.
|
||||
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||
- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the
|
||||
replacement content sent to the agent. **The turn continues.**
|
||||
|
||||
---
|
||||
|
||||
## Agent hooks
|
||||
|
||||
### `BeforeAgent`
|
||||
|
||||
Fires after a user submits a prompt, but before the agent begins planning. Used
|
||||
for prompt validation or injecting dynamic context.
|
||||
|
||||
- **Input Fields**:
|
||||
- `prompt`: (`string`) The original text submitted by the user.
|
||||
- **Relevant Output Fields**:
|
||||
- `hookSpecificOutput.additionalContext`: Text that is **appended** to the
|
||||
prompt for this turn only.
|
||||
- `decision`: Set to `"deny"` to block the turn and **discard the user's
|
||||
message** (it will not appear in history).
|
||||
- `continue`: Set to `false` to block the turn but **save the message to
|
||||
history**.
|
||||
- `reason`: Required if denied or stopped.
|
||||
- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from
|
||||
context. Same as `decision: "deny"`.
|
||||
|
||||
### `AfterAgent`
|
||||
|
||||
Fires once per turn after the model generates its final response. Primary use
|
||||
case is response validation and automatic retries.
|
||||
|
||||
- **Input Fields**:
|
||||
- `prompt`: (`string`) The user's original request.
|
||||
- `prompt_response`: (`string`) The final text generated by the agent.
|
||||
- `stop_hook_active`: (`boolean`) Indicates if this hook is already running as
|
||||
part of a retry sequence.
|
||||
- **Relevant Output Fields**:
|
||||
- `decision`: Set to `"deny"` to **reject the response** and force a retry.
|
||||
- `reason`: Required if denied. This text is sent **to the agent as a new
|
||||
prompt** to request a correction.
|
||||
- `continue`: Set to `false` to **stop the session** without retrying.
|
||||
- `clearContext`: If `true`, clears conversation history (LLM memory) while
|
||||
preserving UI display.
|
||||
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
|
||||
turn using `stderr` as the feedback prompt.
|
||||
|
||||
---
|
||||
|
||||
## Model hooks
|
||||
|
||||
### `BeforeModel`
|
||||
|
||||
Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic
|
||||
request format.
|
||||
|
||||
- **Input Fields**:
|
||||
- `llm_request`: (`object`) Contains `model`, `messages`, and `config`
|
||||
(generation params).
|
||||
- **Relevant Output Fields**:
|
||||
- `hookSpecificOutput.llm_request`: An object that **overrides** parts of the
|
||||
outgoing request (e.g., changing models or temperature).
|
||||
- `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If
|
||||
provided, the CLI skips the LLM call entirely and uses this as the response.
|
||||
- `decision`: Set to `"deny"` to block the request and abort the turn.
|
||||
- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses
|
||||
`stderr` as the error message.
|
||||
|
||||
### `BeforeToolSelection`
|
||||
|
||||
Fires before the LLM decides which tools to call. Used to filter the available
|
||||
toolset or force specific tool modes.
|
||||
|
||||
- **Input Fields**:
|
||||
- `llm_request`: (`object`) Same format as `BeforeModel`.
|
||||
- **Relevant Output Fields**:
|
||||
- `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`)
|
||||
- `"NONE"`: Disables all tools (Wins over other hooks).
|
||||
- `"ANY"`: Forces at least one tool call.
|
||||
- `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist
|
||||
of tool names.
|
||||
- **Union Strategy**: Multiple hooks' whitelists are **combined**.
|
||||
- **Limitations**: Does **not** support `decision`, `continue`, or
|
||||
`systemMessage`.
|
||||
|
||||
### `AfterModel`
|
||||
|
||||
Fires immediately after an LLM response chunk is received. Used for real-time
|
||||
redaction or PII filtering.
|
||||
|
||||
- **Input Fields**:
|
||||
- `llm_request`: (`object`) The original request.
|
||||
- `llm_response`: (`object`) The model's response (or a single chunk during
|
||||
streaming).
|
||||
- **Relevant Output Fields**:
|
||||
- `hookSpecificOutput.llm_response`: An object that **replaces** the model's
|
||||
response chunk.
|
||||
- `decision`: Set to `"deny"` to discard the response chunk and block the
|
||||
turn.
|
||||
- `continue`: Set to `false` to **kill the entire agent loop** immediately.
|
||||
- **Note on Streaming**: Fired for **every chunk** generated by the model.
|
||||
Modifying the response only affects the current chunk.
|
||||
- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's
|
||||
output. Uses `stderr` as the error message.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle & system hooks
|
||||
|
||||
### `SessionStart`
|
||||
|
||||
Fires on application startup, resuming a session, or after a `/clear` command.
|
||||
Used for loading initial context.
|
||||
|
||||
- **Input fields**:
|
||||
- `source`: (`"startup" | "resume" | "clear"`)
|
||||
- **Relevant output fields**:
|
||||
- `hookSpecificOutput.additionalContext`: (`string`)
|
||||
- **Interactive**: Injected as the first turn in history.
|
||||
- **Non-interactive**: Prepended to the user's prompt.
|
||||
- `systemMessage`: Shown at the start of the session.
|
||||
- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup
|
||||
is never blocked.
|
||||
|
||||
### `SessionEnd`
|
||||
|
||||
Fires when the CLI exits or a session is cleared. Used for cleanup or final
|
||||
telemetry.
|
||||
|
||||
- **Input Fields**:
|
||||
- `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`)
|
||||
- **Relevant Output Fields**:
|
||||
- `systemMessage`: Displayed to the user during shutdown.
|
||||
- **Best Effort**: The CLI **will not wait** for this hook to complete and
|
||||
ignores all flow-control fields (`continue`, `decision`).
|
||||
|
||||
### `Notification`
|
||||
|
||||
Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for
|
||||
external logging or cross-platform alerts.
|
||||
|
||||
- **Input Fields**:
|
||||
- `notification_type`: (`"ToolPermission"`)
|
||||
- `message`: Summary of the alert.
|
||||
- `details`: JSON object with alert-specific metadata (e.g., tool name, file
|
||||
path).
|
||||
- **Relevant Output Fields**:
|
||||
- `systemMessage`: Displayed alongside the system alert.
|
||||
- **Observability Only**: This hook **cannot** block alerts or grant permissions
|
||||
automatically. Flow-control fields are ignored.
|
||||
|
||||
### `PreCompress`
|
||||
|
||||
Fires before the CLI summarizes history to save tokens. Used for logging or
|
||||
state saving.
|
||||
|
||||
- **Input Fields**:
|
||||
- `trigger`: (`"auto" | "manual"`)
|
||||
- **Relevant Output Fields**:
|
||||
- `systemMessage`: Displayed to the user before compression.
|
||||
- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the
|
||||
compression process. Flow-control fields are ignored.
|
||||
|
||||
---
|
||||
|
||||
## Stable Model API
|
||||
|
||||
Gemini CLI uses a decoupled format for model interactions to ensure hooks remain
|
||||
stable even if the underlying Gemini SDK changes.
|
||||
Gemini CLI uses these structures to ensure hooks don't break across SDK updates.
|
||||
|
||||
### `LLMRequest` Object
|
||||
|
||||
Used in `BeforeModel` and `BeforeToolSelection`.
|
||||
|
||||
> 💡 **Note**: In v1, model hooks are primarily text-focused. Non-text parts
|
||||
> (like images or function calls) provided in the `content` array will be
|
||||
> simplified to their string representation by the translator.
|
||||
**LLMRequest**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
"model": string,
|
||||
"messages": Array<{
|
||||
"role": "user" | "model" | "system",
|
||||
"content": string | Array<{ "type": string, [key: string]: any }>
|
||||
"content": string // Non-text parts are filtered out for hooks
|
||||
}>,
|
||||
"config"?: {
|
||||
"temperature"?: number,
|
||||
"maxOutputTokens"?: number,
|
||||
"topP"?: number,
|
||||
"topK"?: number
|
||||
},
|
||||
"toolConfig"?: {
|
||||
"mode"?: "AUTO" | "ANY" | "NONE",
|
||||
"allowedFunctionNames"?: string[]
|
||||
}
|
||||
"config": { "temperature": number, ... },
|
||||
"toolConfig": { "mode": string, "allowedFunctionNames": string[] }
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### `LLMResponse` Object
|
||||
|
||||
Used in `AfterModel` and as a synthetic response in `BeforeModel`.
|
||||
**LLMResponse**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
"text"?: string,
|
||||
"candidates": Array<{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": string[]
|
||||
},
|
||||
"finishReason"?: "STOP" | "MAX_TOKENS" | "SAFETY" | "RECITATION" | "OTHER",
|
||||
"index"?: number,
|
||||
"safetyRatings"?: Array<{
|
||||
"category": string,
|
||||
"probability": string,
|
||||
"blocked"?: boolean
|
||||
}>
|
||||
"content": { "role": "model", "parts": string[] },
|
||||
"finishReason": string
|
||||
}>,
|
||||
"usageMetadata"?: {
|
||||
"promptTokenCount"?: number,
|
||||
"candidatesTokenCount"?: number,
|
||||
"totalTokenCount"?: number
|
||||
}
|
||||
"usageMetadata": { "totalTokenCount": number }
|
||||
}
|
||||
```
|
||||
|
||||
+224
-818
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -56,8 +56,8 @@ This documentation is organized into the following sections:
|
||||
commands with `/model`.
|
||||
- **[Sandbox](./cli/sandbox.md):** Isolate tool execution in a secure,
|
||||
containerized environment.
|
||||
- **[Agent Skills](./cli/skills.md):** (Experimental) Extend the CLI with
|
||||
specialized expertise and procedural workflows.
|
||||
- **[Agent Skills](./cli/skills.md):** Extend the CLI with specialized expertise
|
||||
and procedural workflows.
|
||||
- **[Settings](./cli/settings.md):** Configure various aspects of the CLI's
|
||||
behavior and appearance with `/settings`.
|
||||
- **[Telemetry](./cli/telemetry.md):** Overview of telemetry in the CLI.
|
||||
@@ -102,10 +102,10 @@ This documentation is organized into the following sections:
|
||||
|
||||
- **[Introduction: Extensions](./extensions/index.md):** How to extend the CLI
|
||||
with new functionality.
|
||||
- **[Get Started with extensions](./extensions/getting-started-extensions.md):**
|
||||
Learn how to build your own extension.
|
||||
- **[Extension releasing](./extensions/extension-releasing.md):** How to release
|
||||
Gemini CLI extensions.
|
||||
- **[Writing extensions](./extensions/writing-extensions.md):** Learn how to
|
||||
build your own extension.
|
||||
- **[Extension releasing](./extensions/releasing.md):** How to release Gemini
|
||||
CLI extensions.
|
||||
|
||||
### Hooks
|
||||
|
||||
|
||||
+87
-244
@@ -1,187 +1,52 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs"
|
||||
},
|
||||
{
|
||||
"label": "Architecture overview",
|
||||
"slug": "docs/architecture"
|
||||
},
|
||||
{
|
||||
"label": "Contribution guide",
|
||||
"slug": "docs/contributing"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Get started",
|
||||
"items": [
|
||||
{
|
||||
"label": "Gemini CLI quickstart",
|
||||
"slug": "docs/get-started"
|
||||
},
|
||||
{
|
||||
"label": "Gemini 3 on Gemini CLI",
|
||||
"slug": "docs/get-started/gemini-3"
|
||||
},
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{
|
||||
"label": "Configuration",
|
||||
"slug": "docs/get-started/configuration"
|
||||
},
|
||||
{
|
||||
"label": "Installation",
|
||||
"slug": "docs/get-started/installation"
|
||||
},
|
||||
{
|
||||
"label": "Examples",
|
||||
"slug": "docs/get-started/examples"
|
||||
}
|
||||
{ "label": "Overview", "slug": "docs" },
|
||||
{ "label": "Quickstart", "slug": "docs/get-started" },
|
||||
{ "label": "Installation", "slug": "docs/get-started/installation" },
|
||||
{ "label": "Authentication", "slug": "docs/get-started/authentication" },
|
||||
{ "label": "Examples", "slug": "docs/get-started/examples" },
|
||||
{ "label": "Gemini 3 (preview)", "slug": "docs/get-started/gemini-3" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "CLI",
|
||||
"label": "Use Gemini CLI",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/cli"
|
||||
},
|
||||
{
|
||||
"label": "Commands",
|
||||
"slug": "docs/cli/commands"
|
||||
},
|
||||
{
|
||||
"label": "Checkpointing",
|
||||
"slug": "docs/cli/checkpointing"
|
||||
},
|
||||
{
|
||||
"label": "Custom commands",
|
||||
"slug": "docs/cli/custom-commands"
|
||||
},
|
||||
{
|
||||
"label": "Enterprise",
|
||||
"slug": "docs/cli/enterprise"
|
||||
},
|
||||
{
|
||||
"label": "Headless mode",
|
||||
"slug": "docs/cli/headless"
|
||||
},
|
||||
{
|
||||
"label": "Keyboard shortcuts",
|
||||
"slug": "docs/cli/keyboard-shortcuts"
|
||||
},
|
||||
{
|
||||
"label": "Model selection",
|
||||
"slug": "docs/cli/model"
|
||||
},
|
||||
{
|
||||
"label": "Sandbox",
|
||||
"slug": "docs/cli/sandbox"
|
||||
},
|
||||
{
|
||||
"label": "Session Management",
|
||||
"slug": "docs/cli/session-management"
|
||||
},
|
||||
{
|
||||
"label": "Agent Skills",
|
||||
"slug": "docs/cli/skills"
|
||||
},
|
||||
{
|
||||
"label": "Settings",
|
||||
"slug": "docs/cli/settings"
|
||||
},
|
||||
{
|
||||
"label": "Telemetry",
|
||||
"slug": "docs/cli/telemetry"
|
||||
},
|
||||
{
|
||||
"label": "Themes",
|
||||
"slug": "docs/cli/themes"
|
||||
},
|
||||
{
|
||||
"label": "Token caching",
|
||||
"slug": "docs/cli/token-caching"
|
||||
},
|
||||
{
|
||||
"label": "Trusted Folders",
|
||||
"slug": "docs/cli/trusted-folders"
|
||||
},
|
||||
{
|
||||
"label": "Tutorials",
|
||||
"slug": "docs/cli/tutorials"
|
||||
},
|
||||
{
|
||||
"label": "Uninstall",
|
||||
"slug": "docs/cli/uninstall"
|
||||
},
|
||||
{
|
||||
"label": "System prompt override",
|
||||
"slug": "docs/cli/system-prompt"
|
||||
}
|
||||
{ "label": "Using the CLI", "slug": "docs/cli" },
|
||||
{ "label": "File management", "slug": "docs/tools/file-system" },
|
||||
{ "label": "Memory management", "slug": "docs/tools/memory" },
|
||||
{ "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" },
|
||||
{ "label": "Shell commands", "slug": "docs/tools/shell" },
|
||||
{ "label": "Session management", "slug": "docs/cli/session-management" },
|
||||
{ "label": "Todos", "slug": "docs/tools/todos" },
|
||||
{ "label": "Web search and fetch", "slug": "docs/tools/web-search" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Core",
|
||||
"label": "Configuration",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/core"
|
||||
"label": "Ignore files (.geminiignore)",
|
||||
"slug": "docs/cli/gemini-ignore"
|
||||
},
|
||||
{
|
||||
"label": "Tools API",
|
||||
"slug": "docs/core/tools-api"
|
||||
},
|
||||
{
|
||||
"label": "Memory Import Processor",
|
||||
"slug": "docs/core/memport"
|
||||
},
|
||||
{
|
||||
"label": "Policy Engine",
|
||||
"slug": "docs/core/policy-engine"
|
||||
}
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{ "label": "Themes", "slug": "docs/cli/themes" },
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
|
||||
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Tools",
|
||||
"label": "Advanced features",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/tools"
|
||||
},
|
||||
{
|
||||
"label": "File system",
|
||||
"slug": "docs/tools/file-system"
|
||||
},
|
||||
{
|
||||
"label": "Shell",
|
||||
"slug": "docs/tools/shell"
|
||||
},
|
||||
{
|
||||
"label": "Web fetch",
|
||||
"slug": "docs/tools/web-fetch"
|
||||
},
|
||||
{
|
||||
"label": "Web search",
|
||||
"slug": "docs/tools/web-search"
|
||||
},
|
||||
{
|
||||
"label": "Memory",
|
||||
"slug": "docs/tools/memory"
|
||||
},
|
||||
{
|
||||
"label": "Todos",
|
||||
"slug": "docs/tools/todos"
|
||||
},
|
||||
{
|
||||
"label": "MCP servers",
|
||||
"slug": "docs/tools/mcp-server"
|
||||
}
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
|
||||
{ "label": "Enterprise features", "slug": "docs/cli/enterprise" },
|
||||
{ "label": "Headless mode & scripting", "slug": "docs/cli/headless" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -192,106 +57,84 @@
|
||||
"slug": "docs/extensions"
|
||||
},
|
||||
{
|
||||
"label": "Get started with extensions",
|
||||
"slug": "docs/extensions/getting-started-extensions"
|
||||
"label": "Writing extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
},
|
||||
{
|
||||
"label": "Extension releasing",
|
||||
"slug": "docs/extensions/extension-releasing"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Hooks (experimental)",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/hooks"
|
||||
},
|
||||
{
|
||||
"label": "Writing hooks",
|
||||
"slug": "docs/hooks/writing-hooks"
|
||||
},
|
||||
{
|
||||
"label": "Hooks reference",
|
||||
"slug": "docs/hooks/reference"
|
||||
"label": "Extensions reference",
|
||||
"slug": "docs/extensions/reference"
|
||||
},
|
||||
{
|
||||
"label": "Best practices",
|
||||
"slug": "docs/hooks/best-practices"
|
||||
"slug": "docs/extensions/best-practices"
|
||||
},
|
||||
{
|
||||
"label": "Extensions releasing",
|
||||
"slug": "docs/extensions/releasing"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "IDE integration",
|
||||
"label": "Ecosystem and extensibility",
|
||||
"items": [
|
||||
{ "label": "Agent skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/ide-integration"
|
||||
"label": "Sub-agents (experimental)",
|
||||
"slug": "docs/core/subagents"
|
||||
},
|
||||
{
|
||||
"label": "IDE companion spec",
|
||||
"slug": "docs/ide-integration/ide-companion-spec"
|
||||
}
|
||||
"label": "Remote subagents (experimental)",
|
||||
"slug": "docs/core/remote-agents"
|
||||
},
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Tutorials",
|
||||
"items": [
|
||||
{
|
||||
"label": "Get started with extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
},
|
||||
{ "label": "How to write hooks", "slug": "docs/hooks/writing-hooks" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Reference",
|
||||
"items": [
|
||||
{ "label": "Architecture", "slug": "docs/architecture" },
|
||||
{ "label": "Command reference", "slug": "docs/cli/commands" },
|
||||
{ "label": "Configuration", "slug": "docs/get-started/configuration" },
|
||||
{ "label": "Keyboard shortcuts", "slug": "docs/cli/keyboard-shortcuts" },
|
||||
{ "label": "Memory import processor", "slug": "docs/core/memport" },
|
||||
{ "label": "Policy engine", "slug": "docs/core/policy-engine" },
|
||||
{ "label": "Tools API", "slug": "docs/core/tools-api" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Resources",
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/faq" },
|
||||
{ "label": "Quota and pricing", "slug": "docs/quota-and-pricing" },
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Terms and privacy", "slug": "docs/tos-privacy" },
|
||||
{ "label": "Troubleshooting", "slug": "docs/troubleshooting" },
|
||||
{ "label": "Uninstall", "slug": "docs/cli/uninstall" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Development",
|
||||
"items": [
|
||||
{
|
||||
"label": "NPM",
|
||||
"slug": "docs/npm"
|
||||
},
|
||||
{
|
||||
"label": "Releases",
|
||||
"slug": "docs/releases"
|
||||
},
|
||||
{
|
||||
"label": "Integration tests",
|
||||
"slug": "docs/integration-tests"
|
||||
},
|
||||
{ "label": "Contribution guide", "slug": "docs/contributing" },
|
||||
{ "label": "Integration testing", "slug": "docs/integration-tests" },
|
||||
{
|
||||
"label": "Issue and PR automation",
|
||||
"slug": "docs/issue-and-pr-automation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{
|
||||
"label": "Release notes",
|
||||
"slug": "docs/changelogs/"
|
||||
},
|
||||
{
|
||||
"label": "Latest release",
|
||||
"slug": "docs/changelogs/latest"
|
||||
},
|
||||
{
|
||||
"label": "Preview release",
|
||||
"slug": "docs/changelogs/preview"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Support",
|
||||
"items": [
|
||||
{
|
||||
"label": "FAQ",
|
||||
"slug": "docs/faq"
|
||||
},
|
||||
{
|
||||
"label": "Troubleshooting",
|
||||
"slug": "docs/troubleshooting"
|
||||
},
|
||||
{
|
||||
"label": "Quota and pricing",
|
||||
"slug": "docs/quota-and-pricing"
|
||||
},
|
||||
{
|
||||
"label": "Terms of service",
|
||||
"slug": "docs/tos-privacy"
|
||||
}
|
||||
{ "label": "Local development", "slug": "docs/local-development" },
|
||||
{ "label": "NPM package structure", "slug": "docs/npm" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -91,8 +91,8 @@ Additionally, these tools incorporate:
|
||||
|
||||
- **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the
|
||||
Gemini model and your local environment or other services like APIs.
|
||||
- **[Agent Skills](../cli/skills.md)**: (Experimental) On-demand expertise
|
||||
packages that are activated via the `activate_skill` tool to provide
|
||||
specialized guidance and resources.
|
||||
- **[Agent Skills](../cli/skills.md)**: On-demand expertise packages that are
|
||||
activated via the `activate_skill` tool to provide specialized guidance and
|
||||
resources.
|
||||
- **[Sandboxing](../cli/sandbox.md)**: Sandboxing isolates the model and its
|
||||
changes from your environment to reduce potential risk.
|
||||
|
||||
@@ -733,7 +733,7 @@ The MCP integration tracks several states:
|
||||
|
||||
## Important notes
|
||||
|
||||
### Security sonsiderations
|
||||
### Security considerations
|
||||
|
||||
- **Trust settings:** The `trust` option bypasses all confirmation dialogs. Use
|
||||
cautiously and only for servers you completely control
|
||||
@@ -1038,6 +1038,29 @@ gemini mcp remove my-server
|
||||
This will find and delete the "my-server" entry from the `mcpServers` object in
|
||||
the appropriate `settings.json` file based on the scope (`-s, --scope`).
|
||||
|
||||
### Enabling/disabling a server (`gemini mcp enable`, `gemini mcp disable`)
|
||||
|
||||
Temporarily disable an MCP server without removing its configuration, or
|
||||
re-enable a previously disabled server.
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
gemini mcp enable <name> [--session]
|
||||
gemini mcp disable <name> [--session]
|
||||
```
|
||||
|
||||
**Options (flags):**
|
||||
|
||||
- `--session`: Apply change only for this session (not persisted to file).
|
||||
|
||||
Disabled servers appear in `/mcp` status as "Disabled" but won't connect or
|
||||
provide tools. Enablement state is stored in
|
||||
`~/.gemini/mcp-server-enablement.json`.
|
||||
|
||||
The same commands are available as slash commands during an active session:
|
||||
`/mcp enable <name>` and `/mcp disable <name>`.
|
||||
|
||||
## Instructions
|
||||
|
||||
Gemini CLI supports
|
||||
|
||||
@@ -34,9 +34,6 @@ topics on:
|
||||
list of supported locations, see the following pages:
|
||||
- Gemini Code Assist for individuals:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
- Google AI Pro and Ultra where Gemini Code Assist (and Gemini CLI) is also
|
||||
available:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/locations-pro-ultra)
|
||||
|
||||
- **Error: `Failed to login. Message: Request contains an invalid argument`**
|
||||
- **Cause:** Users with Google Workspace accounts or Google Cloud accounts
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ let esbuild;
|
||||
try {
|
||||
esbuild = (await import('esbuild')).default;
|
||||
} catch (_error) {
|
||||
console.warn('esbuild not available, skipping bundle step');
|
||||
process.exit(0);
|
||||
console.error('esbuild not available - cannot build bundle');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
+45
-3
@@ -144,6 +144,48 @@ A significant drop in the pass rate for a `USUALLY_PASSES` test—even if it
|
||||
doesn't drop to 0%—often indicates that a recent change to a system prompt or
|
||||
tool definition has made the model's behavior less reliable.
|
||||
|
||||
You may be able to investigate the regression using Gemini CLI by giving it the
|
||||
link to the runs before and after the change and the name of the test and asking
|
||||
it to investigate what changes may have impacted the test.
|
||||
## Fixing Evaluations
|
||||
|
||||
If an evaluation is failing or has a regressed pass rate, you can use the
|
||||
`/fix-behavioral-eval` command within Gemini CLI to help investigate and fix the
|
||||
issue.
|
||||
|
||||
### `/fix-behavioral-eval`
|
||||
|
||||
This command is designed to automate the investigation and fixing process for
|
||||
failing evaluations. It will:
|
||||
|
||||
1. **Investigate**: Fetch the latest results from the nightly workflow using
|
||||
the `gh` CLI, identify the failing test, and review test trajectory logs in
|
||||
`evals/logs`.
|
||||
2. **Fix**: Suggest and apply targeted fixes to the prompt or tool definitions.
|
||||
It prioritizes minimal changes to `prompt.ts`, tool instructions, and
|
||||
modules that contribute to the prompt. It generally tries to avoid changing
|
||||
the test itself.
|
||||
3. **Verify**: Re-run the test 3 times across multiple models (e.g., Gemini
|
||||
3.0, Gemini 3 Flash, Gemini 2.5 Pro) to ensure stability and calculate a
|
||||
success rate.
|
||||
4. **Report**: Provide a summary of the success rate for each model and details
|
||||
on the applied fixes.
|
||||
|
||||
To use it, run:
|
||||
|
||||
```bash
|
||||
gemini /fix-behavioral-eval
|
||||
```
|
||||
|
||||
You can also provide a link to a specific GitHub Action run or the name of a
|
||||
specific test to focus the investigation:
|
||||
|
||||
```bash
|
||||
gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/runs/123456789
|
||||
```
|
||||
|
||||
When investigating failures manually, you can also enable verbose agent logs by
|
||||
setting the `GEMINI_DEBUG_LOG_FILE` environment variable.
|
||||
|
||||
It's highly recommended to manually review and/or ask the agent to iterate on
|
||||
any prompt changes, even if they pass all evals. The prompt should prefer
|
||||
positive traits ('do X') and resort to negative traits ('do not do X') only when
|
||||
unable to accomplish the goal with positive traits. Gemini is quite good at
|
||||
instrospecting on its prompt when asked the right questions.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import { EDIT_TOOL_NAMES } from '@google/gemini-cli-core';
|
||||
|
||||
const FILES = {
|
||||
'app.ts': 'const add = (a: number, b: number) => a - b;',
|
||||
'package.json': '{"name": "test-app", "version": "1.0.0"}',
|
||||
} as const;
|
||||
|
||||
describe('Answer vs. ask eval', () => {
|
||||
/**
|
||||
* Ensures that when the user asks to "inspect" for bugs, the agent does NOT
|
||||
* automatically modify the file, but instead asks for permission.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when asked to inspect for bugs',
|
||||
prompt: 'Inspect app.ts for bugs',
|
||||
files: FILES,
|
||||
assert: async (rig, result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user explicitly asks to "fix" a bug, the agent
|
||||
* does modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should edit files when asked to fix bug',
|
||||
prompt: 'Fix the bug in app.ts - it should add numbers not subtract',
|
||||
files: FILES,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify edit tools WERE called
|
||||
const editCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name) && log.toolRequest.success,
|
||||
);
|
||||
expect(editCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify file changed
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a + b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user asks "any bugs?" the agent does NOT
|
||||
* automatically modify the file, but instead asks for permission.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit when asking "any bugs"',
|
||||
prompt: 'Any bugs in app.ts?',
|
||||
files: FILES,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user asks a general question, the agent does NOT
|
||||
* automatically modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when asked a general question',
|
||||
prompt: 'How does app.ts work?',
|
||||
files: FILES,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user asks a question about style, the agent does NOT
|
||||
* automatically modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when asked about style',
|
||||
prompt: 'Is app.ts following good style?',
|
||||
files: FILES,
|
||||
assert: async (rig, result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user points out an issue but doesn't ask for a fix,
|
||||
* the agent does NOT automatically modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when user notes an issue',
|
||||
prompt: 'The add function subtracts numbers.',
|
||||
files: FILES,
|
||||
params: { timeout: 20000 }, // 20s timeout
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
describe('generalist_agent', () => {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should be able to use generalist agent by explicitly asking the main agent to invoke it',
|
||||
params: {
|
||||
settings: {
|
||||
@@ -25,14 +25,7 @@ describe('generalist_agent', () => {
|
||||
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
|
||||
assert: async (rig) => {
|
||||
// 1) Verify the generalist agent was invoked via delegate_to_agent
|
||||
const foundToolCall = await rig.waitForToolCall(
|
||||
'delegate_to_agent',
|
||||
undefined,
|
||||
(args) => {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'generalist';
|
||||
},
|
||||
);
|
||||
const foundToolCall = await rig.waitForToolCall('generalist');
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a delegate_to_agent tool call for generalist agent',
|
||||
|
||||
+1
-12
@@ -47,18 +47,7 @@ describe('subagent eval test cases', () => {
|
||||
'README.md': 'TODO: update the README.',
|
||||
},
|
||||
assert: async (rig, _result) => {
|
||||
await rig.expectToolCallSuccess(
|
||||
['delegate_to_agent'],
|
||||
undefined,
|
||||
(args) => {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'docs-agent';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
await rig.expectToolCallSuccess(['docs-agent']);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import * as os from 'node:os';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
// Minimal 1x1 PNG image base64
|
||||
const DUMMY_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
describe('Linux Clipboard Integration', () => {
|
||||
let rig: TestRig;
|
||||
let dummyImagePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
// Create a dummy image file for testing
|
||||
dummyImagePath = path.join(
|
||||
os.tmpdir(),
|
||||
`gemini-test-clipboard-${Date.now()}.png`,
|
||||
);
|
||||
fs.writeFileSync(dummyImagePath, Buffer.from(DUMMY_PNG_BASE64, 'base64'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
try {
|
||||
if (fs.existsSync(dummyImagePath)) {
|
||||
fs.unlinkSync(dummyImagePath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
// Only run this test on Linux
|
||||
const runIfLinux = os.platform() === 'linux' ? it : it.skip;
|
||||
|
||||
runIfLinux(
|
||||
'should paste image from system clipboard when Ctrl+V is pressed',
|
||||
async () => {
|
||||
// 1. Setup rig
|
||||
await rig.setup('linux-clipboard-paste');
|
||||
|
||||
// 2. Inject image into system clipboard
|
||||
// We attempt both Wayland and X11 tools.
|
||||
let clipboardSet = false;
|
||||
|
||||
// Try wl-copy (Wayland)
|
||||
let sessionType = '';
|
||||
const wlCopy = spawnSync('wl-copy', ['--type', 'image/png'], {
|
||||
input: fs.readFileSync(dummyImagePath),
|
||||
});
|
||||
if (wlCopy.status === 0) {
|
||||
clipboardSet = true;
|
||||
sessionType = 'wayland';
|
||||
} else {
|
||||
// Try xclip (X11)
|
||||
try {
|
||||
execSync(
|
||||
`xclip -selection clipboard -t image/png -i "${dummyImagePath}"`,
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
clipboardSet = true;
|
||||
sessionType = 'x11';
|
||||
} catch {
|
||||
// Both failed
|
||||
}
|
||||
}
|
||||
|
||||
if (!clipboardSet) {
|
||||
console.warn(
|
||||
'Skipping test: Could not access system clipboard (wl-copy or xclip required)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Launch CLI and simulate Ctrl+V
|
||||
// We send the control character \u0016 (SYN) which corresponds to Ctrl+V
|
||||
// Note: The CLI must be running and accepting input.
|
||||
// The TestRig usually sends args/stdin and waits for exit or output.
|
||||
// To properly test "interactive" pasting, we need the rig to support sending input *while* running.
|
||||
// Assuming rig.run with 'stdin' sends it immediately.
|
||||
// The CLI treats stdin as typed input if it's interactive.
|
||||
|
||||
// We append a small delay or a newline to ensure processing?
|
||||
// Ctrl+V (\u0016) followed by a newline (\r) to submit?
|
||||
// Or just Ctrl+V and check if the buffer updates (which we can't easily see in non-verbose rig output).
|
||||
// If we send Ctrl+V then Enter, the CLI should submit the prompt containing the image path.
|
||||
|
||||
const result = await rig.run({
|
||||
stdin: '\u0016\r', // Ctrl+V then Enter
|
||||
env: { XDG_SESSION_TYPE: sessionType },
|
||||
});
|
||||
|
||||
// 4. Verify Output
|
||||
// Expect the CLI to have processed the image and echoed back the path (or the prompt containing it)
|
||||
// The output usually contains the user's input echoed back + model response.
|
||||
// The pasted image path should look like @.../clipboard-....png
|
||||
expect(result).toMatch(/@\/.*\.gemini-clipboard\/clipboard-.*\.png/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -155,6 +155,84 @@ describe('Hooks Agent Flow', () => {
|
||||
// The fake response contains "Hello World"
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('Hello World');
|
||||
});
|
||||
|
||||
it('should process clearContext in AfterAgent hook output', async () => {
|
||||
await rig.setup('should process clearContext in AfterAgent hook output', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.after-agent.responses',
|
||||
),
|
||||
});
|
||||
|
||||
// BeforeModel hook to track message counts across LLM calls
|
||||
const messageCountFile = join(rig.testDir!, 'message-counts.json');
|
||||
const beforeModelScript = `
|
||||
const fs = require('fs');
|
||||
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
|
||||
const messageCount = input.llm_request?.contents?.length || 0;
|
||||
let counts = [];
|
||||
try { counts = JSON.parse(fs.readFileSync('${messageCountFile}', 'utf-8')); } catch (e) {}
|
||||
counts.push(messageCount);
|
||||
fs.writeFileSync('${messageCountFile}', JSON.stringify(counts));
|
||||
console.log(JSON.stringify({ decision: 'allow' }));
|
||||
`;
|
||||
const beforeModelScriptPath = join(
|
||||
rig.testDir!,
|
||||
'before_model_counter.cjs',
|
||||
);
|
||||
writeFileSync(beforeModelScriptPath, beforeModelScript);
|
||||
|
||||
await rig.setup('should process clearContext in AfterAgent hook output', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeModel: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${beforeModelScriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
AfterAgent: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node -e "console.log(JSON.stringify({decision: 'block', reason: 'Security policy triggered', hookSpecificOutput: {hookEventName: 'AfterAgent', clearContext: true}}))"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await rig.run({ args: 'Hello test' });
|
||||
|
||||
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
|
||||
expect(hookTelemetryFound).toBeTruthy();
|
||||
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const afterAgentLog = hookLogs.find(
|
||||
(log) => log.hookCall.hook_event_name === 'AfterAgent',
|
||||
);
|
||||
|
||||
expect(afterAgentLog).toBeDefined();
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('clearContext');
|
||||
expect(afterAgentLog?.hookCall.stdout).toContain('true');
|
||||
expect(result).toContain('Security policy triggered');
|
||||
|
||||
// Verify context was cleared: second call should not have more messages than first
|
||||
const countsRaw = rig.readFile('message-counts.json');
|
||||
const counts = JSON.parse(countsRaw) as number[];
|
||||
expect(counts.length).toBeGreaterThanOrEqual(2);
|
||||
expect(counts[1]).toBeLessThanOrEqual(counts[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-step Loops', () => {
|
||||
|
||||
@@ -31,6 +31,18 @@ class MockConfig {
|
||||
getFileFilteringRespectGeminiIgnore() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringOptions() {
|
||||
return {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
validatePathAccess() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ripgrep-real-direct', () => {
|
||||
|
||||
Generated
+258
-1036
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"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.27.0-nightly.20260121.97aac696f"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.0-preview.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -64,7 +64,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -124,7 +124,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -26,10 +26,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../agent/executor.js', () => ({
|
||||
CoderAgentExecutor: vi.fn().mockImplementation(() => ({
|
||||
|
||||
@@ -5,106 +5,127 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { loadConfig } from './config.js';
|
||||
import type { ExtensionLoader } from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
type ExtensionLoader,
|
||||
FileDiscoveryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
const {
|
||||
mockLoadServerHierarchicalMemory,
|
||||
mockConfigConstructor,
|
||||
mockVerifyGitAvailability,
|
||||
} = vi.hoisted(() => ({
|
||||
mockLoadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
mockConfigConstructor: vi.fn(),
|
||||
mockVerifyGitAvailability: vi.fn(),
|
||||
}));
|
||||
// Mock dependencies
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Config: vi.fn().mockImplementation((params) => ({
|
||||
initialize: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
...params, // Expose params for assertion
|
||||
})),
|
||||
loadServerHierarchicalMemory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
FileDiscoveryService: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => ({
|
||||
Config: class MockConfig {
|
||||
constructor(params: unknown) {
|
||||
mockConfigConstructor(params);
|
||||
}
|
||||
initialize = vi.fn();
|
||||
refreshAuth = vi.fn();
|
||||
},
|
||||
loadServerHierarchicalMemory: mockLoadServerHierarchicalMemory,
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
FileDiscoveryService: vi.fn(),
|
||||
ApprovalMode: { DEFAULT: 'default', YOLO: 'yolo' },
|
||||
AuthType: {
|
||||
LOGIN_WITH_GOOGLE: 'login_with_google',
|
||||
USE_GEMINI: 'use_gemini',
|
||||
},
|
||||
GEMINI_DIR: '.gemini',
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL: 'models/embedding-001',
|
||||
DEFAULT_GEMINI_MODEL: 'models/gemini-1.5-flash',
|
||||
PREVIEW_GEMINI_MODEL: 'models/gemini-1.5-pro-latest',
|
||||
homedir: () => '/tmp',
|
||||
GitService: {
|
||||
verifyGitAvailability: mockVerifyGitAvailability,
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('loadConfig', () => {
|
||||
const mockSettings = {
|
||||
checkpointing: { enabled: true },
|
||||
};
|
||||
const mockExtensionLoader = {
|
||||
start: vi.fn(),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
} as unknown as ExtensionLoader;
|
||||
const mockSettings = {} as Settings;
|
||||
const mockExtensionLoader = {} as ExtensionLoader;
|
||||
const taskId = 'test-task-id';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
// Reset the mock return value just in case
|
||||
mockLoadServerHierarchicalMemory.mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['CUSTOM_IGNORE_FILE_PATHS'];
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['CHECKPOINTING'];
|
||||
});
|
||||
|
||||
it('should disable checkpointing if git is not installed', async () => {
|
||||
mockVerifyGitAvailability.mockResolvedValue(false);
|
||||
|
||||
await loadConfig(
|
||||
mockSettings as unknown as Settings,
|
||||
mockExtensionLoader,
|
||||
'test-task',
|
||||
);
|
||||
|
||||
expect(mockConfigConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkpointing: false,
|
||||
}),
|
||||
);
|
||||
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
|
||||
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
|
||||
testPath,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should enable checkpointing if git is installed', async () => {
|
||||
mockVerifyGitAvailability.mockResolvedValue(true);
|
||||
it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => {
|
||||
const testPath = '/settings/ignore';
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
customIgnoreFilePaths: [testPath],
|
||||
},
|
||||
};
|
||||
const config = await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
|
||||
testPath,
|
||||
]);
|
||||
});
|
||||
|
||||
await loadConfig(
|
||||
mockSettings as unknown as Settings,
|
||||
mockExtensionLoader,
|
||||
'test-task',
|
||||
);
|
||||
it('should merge customIgnoreFilePaths from settings and env var', async () => {
|
||||
const envPath = '/env/ignore';
|
||||
const settingsPath = '/settings/ignore';
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = envPath;
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
customIgnoreFilePaths: [settingsPath],
|
||||
},
|
||||
};
|
||||
const config = await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
|
||||
settingsPath,
|
||||
envPath,
|
||||
]);
|
||||
});
|
||||
|
||||
expect(mockConfigConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkpointing: true,
|
||||
}),
|
||||
);
|
||||
it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
|
||||
const paths = ['/path/one', '/path/two'];
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = paths.join(path.delimiter);
|
||||
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
|
||||
});
|
||||
|
||||
it('should have empty customIgnoreFilePaths when both are missing', async () => {
|
||||
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize FileDiscoveryService with correct options', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: undefined,
|
||||
customIgnoreFilePaths: [testPath],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,8 +86,15 @@ export async function loadConfig(
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering: {
|
||||
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
|
||||
respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
|
||||
enableRecursiveFileSearch:
|
||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||
customIgnoreFilePaths: [
|
||||
...(settings.fileFiltering?.customIgnoreFilePaths || []),
|
||||
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
|
||||
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
|
||||
: []),
|
||||
],
|
||||
},
|
||||
ideMode: false,
|
||||
folderTrust,
|
||||
@@ -97,9 +104,14 @@ export async function loadConfig(
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
interactive: true,
|
||||
enableInteractiveShell: true,
|
||||
ptyInfo: 'auto',
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir);
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
respectGitIgnore: configParams?.fileFiltering?.respectGitIgnore,
|
||||
respectGeminiIgnore: configParams?.fileFiltering?.respectGeminiIgnore,
|
||||
customIgnoreFilePaths: configParams?.fileFiltering?.customIgnoreFilePaths,
|
||||
});
|
||||
const { memoryContent, fileCount, filePaths } =
|
||||
await loadServerHierarchicalMemory(
|
||||
workspaceDir,
|
||||
|
||||
@@ -38,7 +38,9 @@ export interface Settings {
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
customIgnoreFilePaths?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
## React & Ink (CLI UI)
|
||||
|
||||
- **Side Effects**: Use reducers for complex state transitions; avoid `setState`
|
||||
triggers in callbacks.
|
||||
- Always fix react-hooks/exhaustive-deps lint errors by adding the missing
|
||||
dependencies.
|
||||
- **Shortcuts**: only define keyboard shortcuts in
|
||||
`packages/cli/src/config/keyBindings.ts
|
||||
|
||||
## Testing
|
||||
|
||||
- **Utilities**: Use `renderWithProviders` and `waitFor` from
|
||||
`packages/cli/src/test-utils/`.
|
||||
- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output.
|
||||
- **Mocks**: Use mocks as sparingly as possilble.
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { render, Box, Text } from 'ink';
|
||||
import { AskUserDialog } from '../src/ui/components/AskUserDialog.js';
|
||||
import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
|
||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||
|
||||
const DEMO_QUESTIONS: Question[] = [
|
||||
{
|
||||
question: 'What type of project are you building?',
|
||||
header: 'Project Type',
|
||||
options: [
|
||||
{ label: 'Web Application', description: 'React, Next.js, or similar' },
|
||||
{ label: 'CLI Tool', description: 'Command-line interface with Node.js' },
|
||||
{ label: 'Library', description: 'NPM package or shared utility' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which features should be enabled?',
|
||||
header: 'Features',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: 'Add static typing' },
|
||||
{ label: 'ESLint', description: 'Add linting and formatting' },
|
||||
{ label: 'Unit Tests', description: 'Add Vitest setup' },
|
||||
{ label: 'CI/CD', description: 'Add GitHub Actions' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
{
|
||||
question: 'What is the project name?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
placeholder: 'my-awesome-project',
|
||||
},
|
||||
{
|
||||
question: 'Initialize git repository?',
|
||||
header: 'Git',
|
||||
type: QuestionType.YESNO,
|
||||
},
|
||||
];
|
||||
|
||||
const Demo = () => {
|
||||
const [result, setResult] = useState<null | { [key: string]: string }>(null);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
|
||||
if (cancelled) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text color="red">
|
||||
Dialog was cancelled. Project initialization aborted.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
borderStyle="single"
|
||||
borderColor="green"
|
||||
>
|
||||
<Text bold color="green">
|
||||
Success! Project Configuration:
|
||||
</Text>
|
||||
{DEMO_QUESTIONS.map((q, i) => (
|
||||
<Box key={i} marginTop={1}>
|
||||
<Text color="gray">{q.header}: </Text>
|
||||
<Text>{result[i] || '(not answered)'}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text color="dim">Press Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeypressProvider>
|
||||
<Box padding={1} flexDirection="column">
|
||||
<Text bold marginBottom={1}>
|
||||
AskUserDialog Demo
|
||||
</Text>
|
||||
<AskUserDialog
|
||||
questions={DEMO_QUESTIONS}
|
||||
onSubmit={setResult}
|
||||
onCancel={() => setCancelled(true)}
|
||||
/>
|
||||
</Box>
|
||||
</KeypressProvider>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Demo />);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.0-nightly.20260121.97aac696f"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.0-preview.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -40,13 +40,13 @@
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^7.0.0",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.1.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
@@ -73,7 +73,6 @@
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@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",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ExtensionSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import prompts from 'prompts';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const {
|
||||
mockExtensionManager,
|
||||
@@ -79,11 +80,15 @@ vi.mock('../../config/settings.js', () => ({
|
||||
}));
|
||||
|
||||
describe('extensions configure command', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(debugLogger, 'log');
|
||||
vi.spyOn(debugLogger, 'error');
|
||||
vi.clearAllMocks();
|
||||
|
||||
tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace');
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
// Default behaviors
|
||||
mockLoadSettings.mockReturnValue({ merged: {} });
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
@@ -141,6 +146,7 @@ describe('extensions configure command', () => {
|
||||
'TEST_VAR',
|
||||
promptForSetting,
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -186,6 +192,7 @@ describe('extensions configure command', () => {
|
||||
'VAR_1',
|
||||
promptForSetting,
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ async function configureSpecificSetting(
|
||||
settingKey,
|
||||
promptForSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,6 +175,7 @@ async function configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
let workspaceSettings: Record<string, string> = {};
|
||||
@@ -182,6 +184,7 @@ async function configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,6 +219,7 @@ async function configureExtensionSettings(
|
||||
setting.envVar,
|
||||
promptForSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@ vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnablementInstance = vi.hoisted(() => ({
|
||||
getDisplayState: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
clearSessionDisable: vi.fn(),
|
||||
autoEnableServers: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/mcp/mcpServerEnablement.js', () => ({
|
||||
McpServerEnablementManager: {
|
||||
getInstance: () => mockEnablementInstance,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('extensions enable command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockExtensionManager = vi.mocked(ExtensionManager);
|
||||
@@ -75,6 +87,12 @@ describe('extensions enable command', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
mockExtensionManager.prototype.enableExtension = vi.fn();
|
||||
mockExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||
mockEnablementInstance.getDisplayState.mockReset();
|
||||
mockEnablementInstance.enable.mockReset();
|
||||
mockEnablementInstance.clearSessionDisable.mockReset();
|
||||
mockEnablementInstance.autoEnableServers.mockReset();
|
||||
mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -134,6 +152,50 @@ describe('extensions enable command', () => {
|
||||
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should auto-enable disabled MCP servers for the extension', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockEnablementInstance.autoEnableServers.mockResolvedValue([
|
||||
'test-server',
|
||||
]);
|
||||
mockExtensionManager.prototype.getExtensions = vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: 'my-extension', mcpServers: { 'test-server': {} } },
|
||||
]);
|
||||
|
||||
await handleEnable({ name: 'my-extension' });
|
||||
|
||||
expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
|
||||
'test-server',
|
||||
]);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining("MCP server 'test-server' was disabled"),
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should not log when MCP servers are already enabled', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
|
||||
mockExtensionManager.prototype.getExtensions = vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: 'my-extension', mcpServers: { 'test-server': {} } },
|
||||
]);
|
||||
|
||||
await handleEnable({ name: 'my-extension' });
|
||||
|
||||
expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
|
||||
'test-server',
|
||||
]);
|
||||
expect(emitConsoleLog).not.toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining("MCP server 'test-server' was disabled"),
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableCommand', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
|
||||
interface EnableArgs {
|
||||
name: string;
|
||||
@@ -37,6 +38,26 @@ export async function handleEnable(args: EnableArgs) {
|
||||
} else {
|
||||
await extensionManager.enableExtension(args.name, SettingScope.User);
|
||||
}
|
||||
|
||||
// Auto-enable any disabled MCP servers for this extension
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === args.name);
|
||||
|
||||
if (extension?.mcpServers) {
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
const enabledServers = await mcpEnablementManager.autoEnableServers(
|
||||
Object.keys(extension.mcpServers ?? {}),
|
||||
);
|
||||
|
||||
for (const serverName of enabledServers) {
|
||||
debugLogger.log(
|
||||
`MCP server '${serverName}' was disabled - now enabled.`,
|
||||
);
|
||||
}
|
||||
// Note: No restartServer() - CLI exits immediately, servers load on next session
|
||||
}
|
||||
|
||||
if (args.scope) {
|
||||
debugLogger.log(
|
||||
`Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Ink Library Screen Reader Guidance
|
||||
|
||||
When building custom components, it's important to keep accessibility in mind.
|
||||
While Ink provides the building blocks, ensuring your components are accessible
|
||||
will make your CLIs usable by a wider audience.
|
||||
|
||||
## General Principles
|
||||
|
||||
Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to
|
||||
detect if a screen reader is active. You can then render a more descriptive
|
||||
output for screen reader users. Leverage ARIA props: For components that have a
|
||||
specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and
|
||||
aria-label props on <Box> and <Text> to provide semantic meaning to screen
|
||||
readers.
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "context-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -78,6 +78,17 @@ describe('extensions list command', () => {
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should output empty JSON array if no extensions are installed and output-format is json', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
await handleList({ outputFormat: 'json' });
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith('log', '[]');
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should list all installed extensions', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const extensions = [
|
||||
@@ -99,6 +110,24 @@ describe('extensions list command', () => {
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should list all installed extensions in JSON format', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const extensions = [
|
||||
{ name: 'ext1', version: '1.0.0' },
|
||||
{ name: 'ext2', version: '2.0.0' },
|
||||
];
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(extensions);
|
||||
await handleList({ outputFormat: 'json' });
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
JSON.stringify(extensions, null, 2),
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log an error message and exit with code 1 when listing fails', async () => {
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
@@ -130,11 +159,35 @@ describe('extensions list command', () => {
|
||||
expect(command.describe).toBe('Lists installed extensions.');
|
||||
});
|
||||
|
||||
it('handler should call handleList', async () => {
|
||||
it('builder should have output-format option', () => {
|
||||
const mockYargs = {
|
||||
option: vi.fn().mockReturnThis(),
|
||||
};
|
||||
(
|
||||
command.builder as unknown as (
|
||||
yargs: typeof mockYargs,
|
||||
) => typeof mockYargs
|
||||
)(mockYargs);
|
||||
expect(mockYargs.option).toHaveBeenCalledWith('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
describe: 'The format of the CLI output.',
|
||||
choices: ['text', 'json'],
|
||||
default: 'text',
|
||||
});
|
||||
});
|
||||
|
||||
it('handler should call handleList with parsed arguments', async () => {
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
await (command.handler as () => Promise<void>)();
|
||||
await (
|
||||
command.handler as unknown as (args: {
|
||||
'output-format': string;
|
||||
}) => Promise<void>
|
||||
)({
|
||||
'output-format': 'json',
|
||||
});
|
||||
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { loadSettings } from '../../config/settings.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
export async function handleList() {
|
||||
export async function handleList(options?: { outputFormat?: 'text' | 'json' }) {
|
||||
try {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
@@ -24,16 +24,25 @@ export async function handleList() {
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
if (extensions.length === 0) {
|
||||
debugLogger.log('No extensions installed.');
|
||||
if (options?.outputFormat === 'json') {
|
||||
debugLogger.log('[]');
|
||||
} else {
|
||||
debugLogger.log('No extensions installed.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
debugLogger.log(
|
||||
extensions
|
||||
.map((extension, _): string =>
|
||||
extensionManager.toOutputString(extension),
|
||||
)
|
||||
.join('\n\n'),
|
||||
);
|
||||
|
||||
if (options?.outputFormat === 'json') {
|
||||
debugLogger.log(JSON.stringify(extensions, null, 2));
|
||||
} else {
|
||||
debugLogger.log(
|
||||
extensions
|
||||
.map((extension, _): string =>
|
||||
extensionManager.toOutputString(extension),
|
||||
)
|
||||
.join('\n\n'),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
@@ -43,9 +52,18 @@ export async function handleList() {
|
||||
export const listCommand: CommandModule = {
|
||||
command: 'list',
|
||||
describe: 'Lists installed extensions.',
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
await handleList();
|
||||
builder: (yargs) =>
|
||||
yargs.option('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
describe: 'The format of the CLI output.',
|
||||
choices: ['text', 'json'],
|
||||
default: 'text',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleList({
|
||||
outputFormat: argv['output-format'] as 'text' | 'json',
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -511,8 +511,5 @@ describe('migrate command', () => {
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,9 +244,6 @@ export async function handleMigrateFromClaude() {
|
||||
debugLogger.log(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
debugLogger.log(
|
||||
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('mcp command', () => {
|
||||
|
||||
(mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv);
|
||||
|
||||
expect(mockYargs.command).toHaveBeenCalledTimes(3);
|
||||
expect(mockYargs.command).toHaveBeenCalledTimes(5);
|
||||
|
||||
// Verify that the specific subcommands are registered
|
||||
const commandCalls = mockYargs.command.mock.calls;
|
||||
@@ -70,6 +70,8 @@ describe('mcp command', () => {
|
||||
expect(commandNames).toContain('add <name> <commandOrUrl> [args...]');
|
||||
expect(commandNames).toContain('remove <name>');
|
||||
expect(commandNames).toContain('list');
|
||||
expect(commandNames).toContain('enable <name>');
|
||||
expect(commandNames).toContain('disable <name>');
|
||||
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
|
||||
1,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { CommandModule, Argv } from 'yargs';
|
||||
import { addCommand } from './mcp/add.js';
|
||||
import { removeCommand } from './mcp/remove.js';
|
||||
import { listCommand } from './mcp/list.js';
|
||||
import { enableCommand, disableCommand } from './mcp/enableDisable.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
@@ -24,6 +25,8 @@ export const mcpCommand: CommandModule = {
|
||||
.command(defer(addCommand, 'mcp'))
|
||||
.command(defer(removeCommand, 'mcp'))
|
||||
.command(defer(listCommand, 'mcp'))
|
||||
.command(defer(enableCommand, 'mcp'))
|
||||
.command(defer(disableCommand, 'mcp'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
McpServerEnablementManager,
|
||||
canLoadServer,
|
||||
normalizeServerId,
|
||||
} from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { getMcpServersFromConfig } from './list.js';
|
||||
|
||||
const GREEN = '\x1b[32m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const RED = '\x1b[31m';
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
interface Args {
|
||||
name: string;
|
||||
session?: boolean;
|
||||
}
|
||||
|
||||
async function handleEnable(args: Args): Promise<void> {
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
const name = normalizeServerId(args.name);
|
||||
|
||||
// Check settings blocks
|
||||
const settings = loadSettings();
|
||||
|
||||
// Get all servers including extensions
|
||||
const servers = await getMcpServersFromConfig();
|
||||
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
|
||||
if (!normalizedServerNames.includes(name)) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await canLoadServer(name, {
|
||||
adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true,
|
||||
allowedList: settings.merged.mcp?.allowed,
|
||||
excludedList: settings.merged.mcp?.excluded,
|
||||
});
|
||||
|
||||
if (
|
||||
!result.allowed &&
|
||||
(result.blockType === 'allowlist' || result.blockType === 'excludelist')
|
||||
) {
|
||||
debugLogger.log(`${RED}Error:${RESET} ${result.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.session) {
|
||||
manager.clearSessionDisable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} Session disable cleared for '${name}'.`);
|
||||
} else {
|
||||
await manager.enable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' enabled.`);
|
||||
}
|
||||
|
||||
if (result.blockType === 'admin') {
|
||||
debugLogger.log(
|
||||
`${YELLOW}Warning:${RESET} MCP servers are disabled by administrator.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(args: Args): Promise<void> {
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
const name = normalizeServerId(args.name);
|
||||
|
||||
// Get all servers including extensions
|
||||
const servers = await getMcpServersFromConfig();
|
||||
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
|
||||
if (!normalizedServerNames.includes(name)) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.session) {
|
||||
manager.disableForSession(name);
|
||||
debugLogger.log(
|
||||
`${GREEN}✓${RESET} MCP server '${name}' disabled for this session.`,
|
||||
);
|
||||
} else {
|
||||
await manager.disable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' disabled.`);
|
||||
}
|
||||
}
|
||||
|
||||
export const enableCommand: CommandModule<object, Args> = {
|
||||
command: 'enable <name>',
|
||||
describe: 'Enable an MCP server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'MCP server name to enable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('session', {
|
||||
describe: 'Clear session-only disable',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleEnable(argv as Args);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
export const disableCommand: CommandModule<object, Args> = {
|
||||
command: 'disable <name>',
|
||||
describe: 'Disable an MCP server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'MCP server name to disable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('session', {
|
||||
describe: 'Disable for current session only',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleDisable(argv as Args);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -24,7 +24,7 @@ const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
async function getMcpServersFromConfig(): Promise<
|
||||
export async function getMcpServersFromConfig(): Promise<
|
||||
Record<string, MCPServerConfig>
|
||||
> {
|
||||
const settings = loadSettings();
|
||||
|
||||
@@ -57,7 +57,9 @@ vi.mock('fs', async (importOriginal) => {
|
||||
|
||||
return {
|
||||
...actualFs,
|
||||
mkdirSync: vi.fn(),
|
||||
mkdirSync: vi.fn((p) => {
|
||||
mockPaths.add(p.toString());
|
||||
}),
|
||||
writeFileSync: vi.fn(),
|
||||
existsSync: vi.fn((p) => mockPaths.has(p.toString())),
|
||||
statSync: vi.fn((p) => {
|
||||
@@ -124,10 +126,12 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
},
|
||||
DEFAULT_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
},
|
||||
createPolicyEngineConfig: vi.fn(async () => ({
|
||||
rules: [],
|
||||
@@ -371,6 +375,21 @@ describe('parseArguments', () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('should include a startup message when converting positional query to interactive prompt', async () => {
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', 'hello'];
|
||||
|
||||
try {
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
expect(argv.startupMessages).toContain(
|
||||
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
|
||||
);
|
||||
} finally {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -687,6 +706,9 @@ describe('loadCliConfig', () => {
|
||||
expect(config.getFileFilteringRespectGeminiIgnore()).toBe(
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore,
|
||||
);
|
||||
expect(config.getCustomIgnoreFilePaths()).toEqual(
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.customIgnoreFilePaths,
|
||||
);
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
@@ -1953,7 +1975,7 @@ describe('loadCliConfig interactive', () => {
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be interactive if positional prompt words are provided with other flags', async () => {
|
||||
it('should be interactive if positional prompt words are provided with other flags', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro', 'Hello'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -1962,10 +1984,10 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be interactive if positional prompt words are provided with multiple flags', async () => {
|
||||
it('should be interactive if positional prompt words are provided with multiple flags', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = [
|
||||
'node',
|
||||
@@ -1981,13 +2003,13 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
// Verify the question is preserved for one-shot execution
|
||||
expect(argv.prompt).toBe('Hello world');
|
||||
expect(argv.promptInteractive).toBeUndefined();
|
||||
expect(argv.prompt).toBeUndefined();
|
||||
expect(argv.promptInteractive).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should not be interactive if positional prompt words are provided with extensions flag', async () => {
|
||||
it('should be interactive if positional prompt words are provided with extensions flag', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', '-e', 'none', 'hello'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -1996,8 +2018,9 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(argv.query).toBe('hello');
|
||||
expect(argv.promptInteractive).toBe('hello');
|
||||
expect(argv.extensions).toEqual(['none']);
|
||||
});
|
||||
|
||||
@@ -2010,9 +2033,9 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(argv.query).toBe('hello world how are you');
|
||||
expect(argv.prompt).toBe('hello world how are you');
|
||||
expect(argv.promptInteractive).toBe('hello world how are you');
|
||||
});
|
||||
|
||||
it('should handle multiple positional words with flags', async () => {
|
||||
@@ -2035,8 +2058,9 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(argv.query).toBe('write a function to sort array');
|
||||
expect(argv.promptInteractive).toBe('write a function to sort array');
|
||||
expect(argv.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
@@ -2072,8 +2096,9 @@ describe('loadCliConfig interactive', () => {
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
expect(config.isInteractive()).toBe(false);
|
||||
expect(config.isInteractive()).toBe(true);
|
||||
expect(argv.query).toBe('hello world how are you');
|
||||
expect(argv.promptInteractive).toBe('hello world how are you');
|
||||
expect(argv.extensions).toEqual(['none']);
|
||||
});
|
||||
|
||||
@@ -2215,6 +2240,19 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should ignore "yolo" in settings.tools.approvalMode and fall back to DEFAULT', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: {
|
||||
// @ts-expect-error: testing invalid value
|
||||
approvalMode: 'yolo',
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -2292,6 +2330,67 @@ describe('loadCliConfig approval mode', () => {
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Persistent approvalMode setting', () => {
|
||||
it('should use approvalMode from settings when no CLI flags are set', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'auto_edit' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(
|
||||
ServerConfig.ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize --approval-mode flag over settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'default' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(
|
||||
ServerConfig.ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize --yolo flag over settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--yolo'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'auto_edit' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
|
||||
});
|
||||
|
||||
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'plan' },
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
|
||||
});
|
||||
|
||||
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'plan' },
|
||||
experimental: { plan: false },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
await expect(
|
||||
loadCliConfig(settings, 'test-session', argv),
|
||||
).rejects.toThrow(
|
||||
'Approval mode "plan" is only available when experimental.plan is enabled.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig fileFiltering', () => {
|
||||
@@ -2708,9 +2807,9 @@ describe('PolicyEngine nonInteractive wiring', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should set nonInteractive to true in one-shot mode', async () => {
|
||||
it('should set nonInteractive to true when -p flag is used', async () => {
|
||||
process.stdin.isTTY = true;
|
||||
process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot
|
||||
process.argv = ['node', 'script.js', '-p', 'echo hello'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings(),
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
@@ -47,12 +48,12 @@ import {
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { appEvents } from '../utils/events.js';
|
||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { createPolicyEngineConfig } from './policy.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js';
|
||||
import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
|
||||
import { requestConsentNonInteractive } from './extensions/consent.js';
|
||||
import { promptForSetting } from './extensions/extensionSettings.js';
|
||||
@@ -83,6 +84,7 @@ export interface CliArgs {
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
recordResponses: string | undefined;
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
@@ -92,11 +94,12 @@ export async function parseArguments(
|
||||
settings: MergedSettings,
|
||||
): Promise<CliArgs> {
|
||||
const rawArgv = hideBin(process.argv);
|
||||
const startupMessages: string[] = [];
|
||||
const yargsInstance = yargs(rawArgv)
|
||||
.locale('en')
|
||||
.scriptName('gemini')
|
||||
.usage(
|
||||
'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
|
||||
'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.',
|
||||
)
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
@@ -108,7 +111,7 @@ export async function parseArguments(
|
||||
yargsInstance
|
||||
.positional('query', {
|
||||
description:
|
||||
'Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive.',
|
||||
'Initial prompt. Runs in interactive mode by default; use -p/--prompt for non-interactive.',
|
||||
})
|
||||
.option('model', {
|
||||
alias: 'm',
|
||||
@@ -120,7 +123,8 @@ export async function parseArguments(
|
||||
alias: 'p',
|
||||
type: 'string',
|
||||
nargs: 1,
|
||||
description: 'Prompt. Appended to input on stdin (if any).',
|
||||
description:
|
||||
'Run in non-interactive (headless) mode with the given prompt. Appended to input on stdin (if any).',
|
||||
})
|
||||
.option('prompt-interactive', {
|
||||
alias: 'i',
|
||||
@@ -341,11 +345,12 @@ export async function parseArguments(
|
||||
? queryArg.join(' ')
|
||||
: queryArg;
|
||||
|
||||
// Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands)
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
if (q && !result['prompt']) {
|
||||
const hasExplicitInteractive =
|
||||
result['promptInteractive'] === '' || !!result['promptInteractive'];
|
||||
if (hasExplicitInteractive) {
|
||||
if (process.stdin.isTTY) {
|
||||
startupMessages.push(
|
||||
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
|
||||
);
|
||||
result['promptInteractive'] = q;
|
||||
} else {
|
||||
result['prompt'] = q;
|
||||
@@ -354,6 +359,7 @@ export async function parseArguments(
|
||||
|
||||
// Keep CliArgs.query as a string for downstream typing
|
||||
(result as Record<string, unknown>)['query'] = q || undefined;
|
||||
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
@@ -461,7 +467,7 @@ export async function loadCliConfig(
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
eventEmitter: appEvents as EventEmitter<ExtensionEvents>,
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
@@ -496,9 +502,15 @@ export async function loadCliConfig(
|
||||
|
||||
// Determine approval mode with backward compatibility
|
||||
let approvalMode: ApprovalMode;
|
||||
if (argv.approvalMode) {
|
||||
// New --approval-mode flag takes precedence
|
||||
switch (argv.approvalMode) {
|
||||
const rawApprovalMode =
|
||||
argv.approvalMode ||
|
||||
(argv.yolo ? 'yolo' : undefined) ||
|
||||
((settings.tools?.approvalMode as string) !== 'yolo'
|
||||
? settings.tools.approvalMode
|
||||
: undefined);
|
||||
|
||||
if (rawApprovalMode) {
|
||||
switch (rawApprovalMode) {
|
||||
case 'yolo':
|
||||
approvalMode = ApprovalMode.YOLO;
|
||||
break;
|
||||
@@ -518,13 +530,11 @@ export async function loadCliConfig(
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, plan, default`,
|
||||
`Invalid approval mode: ${rawApprovalMode}. Valid values are: yolo, auto_edit, plan, default`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Fallback to legacy --yolo flag behavior
|
||||
approvalMode =
|
||||
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
|
||||
approvalMode = ApprovalMode.DEFAULT;
|
||||
}
|
||||
|
||||
// Override approval mode if disableYoloMode is set.
|
||||
@@ -572,12 +582,12 @@ export async function loadCliConfig(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
|
||||
const hasQuery = !!argv.query;
|
||||
// -p/--prompt forces non-interactive (headless) mode
|
||||
// -i/--prompt-interactive forces interactive mode with an initial prompt
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.experimentalAcp ||
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt && !argv.isCommand);
|
||||
(process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
@@ -665,6 +675,12 @@ export async function loadCliConfig(
|
||||
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
|
||||
const adminSkillsEnabled = settings.admin?.skills?.enabled ?? true;
|
||||
|
||||
// Create MCP enablement manager and callbacks
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
const mcpEnablementCallbacks = mcpEnabled
|
||||
? mcpEnablementManager.getEnablementCallbacks()
|
||||
: undefined;
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
clientVersion: await getVersion(),
|
||||
@@ -686,6 +702,7 @@ export async function loadCliConfig(
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
|
||||
mcpServers: mcpEnabled ? settings.mcpServers : {},
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
extensionsEnabled,
|
||||
agents: settings.agents,
|
||||
@@ -749,6 +766,7 @@ export async function loadCliConfig(
|
||||
folderTrust,
|
||||
interactive,
|
||||
trustedFolder,
|
||||
useBackgroundColor: settings.ui?.useBackgroundColor,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
@@ -759,14 +777,11 @@ export async function loadCliConfig(
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
output: {
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
},
|
||||
codebaseInvestigatorSettings:
|
||||
settings.experimental?.codebaseInvestigatorSettings,
|
||||
cliHelpAgentSettings: settings.experimental?.cliHelpAgentSettings,
|
||||
fakeResponses: argv.fakeResponses,
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
@@ -778,7 +793,7 @@ export async function loadCliConfig(
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks:
|
||||
(settings.tools?.enableHooks ?? true) &&
|
||||
(settings.hooksConfig?.enabled ?? false),
|
||||
(settings.hooksConfig?.enabled ?? true),
|
||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @google/gemini-cli-core
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
// Use actual implementations for loading skills and agents to test hydration
|
||||
loadAgentsFromDirectory: actual.loadAgentsFromDirectory,
|
||||
loadSkillsFromDir: actual.loadSkillsFromDir,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionManager hydration', () => {
|
||||
let extensionManager: ExtensionManager;
|
||||
let tempDir: string;
|
||||
let extensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(coreEvents, 'emitFeedback');
|
||||
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
||||
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
mockHomedir.mockReturnValue(tempDir);
|
||||
|
||||
// Create the extensions directory that ExtensionManager expects
|
||||
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn(),
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should hydrate skill body with extension settings', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source-ext-skill');
|
||||
const extensionName = 'skill-hydration-ext';
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'API Key',
|
||||
description: 'API Key',
|
||||
envVar: 'MY_API_KEY',
|
||||
},
|
||||
],
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, extensionName),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, extensionName);
|
||||
|
||||
// Create skill with variable
|
||||
const skillsDir = path.join(extensionPath, 'skills');
|
||||
const skillSubdir = path.join(skillsDir, 'my-skill');
|
||||
fs.mkdirSync(skillSubdir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillSubdir, 'SKILL.md'),
|
||||
`---
|
||||
name: my-skill
|
||||
description: test
|
||||
---
|
||||
Use key: \${MY_API_KEY}
|
||||
`,
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
extensionManager.setRequestSetting(async (setting) => {
|
||||
if (setting.envVar === 'MY_API_KEY') return 'secret-123';
|
||||
return '';
|
||||
});
|
||||
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
|
||||
expect(extension.skills).toHaveLength(1);
|
||||
expect(extension.skills![0].body).toContain('Use key: secret-123');
|
||||
});
|
||||
|
||||
it('should hydrate agent system prompt with extension settings', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source-ext-agent');
|
||||
const extensionName = 'agent-hydration-ext';
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'Model Name',
|
||||
description: 'Model',
|
||||
envVar: 'MODEL_NAME',
|
||||
},
|
||||
],
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, extensionName),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, extensionName);
|
||||
|
||||
// Create agent with variable
|
||||
const agentsDir = path.join(extensionPath, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(agentsDir, 'my-agent.md'),
|
||||
`---
|
||||
name: my-agent
|
||||
description: test
|
||||
---
|
||||
System using model: \${MODEL_NAME}
|
||||
`,
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
extensionManager.setRequestSetting(async (setting) => {
|
||||
if (setting.envVar === 'MODEL_NAME') return 'gemini-pro';
|
||||
return '';
|
||||
});
|
||||
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
|
||||
expect(extension.agents).toHaveLength(1);
|
||||
const agent = extension.agents![0];
|
||||
if (agent.kind === 'local') {
|
||||
expect(agent.promptConfig.systemPrompt).toContain(
|
||||
'System using model: gemini-pro',
|
||||
);
|
||||
} else {
|
||||
throw new Error('Expected local agent');
|
||||
}
|
||||
});
|
||||
|
||||
it('should hydrate hooks with extension settings', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source-ext-hooks');
|
||||
const extensionName = 'hooks-hydration-ext';
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'Hook Command',
|
||||
description: 'Cmd',
|
||||
envVar: 'HOOK_CMD',
|
||||
},
|
||||
],
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, extensionName),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, extensionName);
|
||||
|
||||
const hooksDir = path.join(extensionPath, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(hooksDir, 'hooks.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'echo $HOOK_CMD',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Enable hooks in settings
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
tools: { enableHooks: true },
|
||||
hooksConfig: { enabled: true },
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn(),
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
extensionManager.setRequestSetting(async (setting) => {
|
||||
if (setting.envVar === 'HOOK_CMD') return 'hello-world';
|
||||
return '';
|
||||
});
|
||||
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
|
||||
expect(extension.hooks).toBeDefined();
|
||||
expect(extension.hooks?.BeforeTool).toHaveLength(1);
|
||||
expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
|
||||
'hello-world',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pick up new settings after restartExtension', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source-ext-restart');
|
||||
const extensionName = 'restart-hydration-ext';
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'Value',
|
||||
description: 'Val',
|
||||
envVar: 'MY_VALUE',
|
||||
},
|
||||
],
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, extensionName),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, extensionName);
|
||||
|
||||
const skillsDir = path.join(extensionPath, 'skills');
|
||||
const skillSubdir = path.join(skillsDir, 'my-skill');
|
||||
fs.mkdirSync(skillSubdir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillSubdir, 'SKILL.md'),
|
||||
'---\nname: my-skill\ndescription: test\n---\nValue is: ${MY_VALUE}',
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// Initial setting
|
||||
extensionManager.setRequestSetting(async () => 'first');
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
expect(extension.skills![0].body).toContain('Value is: first');
|
||||
|
||||
const { updateSetting, ExtensionSettingScope } = await import(
|
||||
'./extensions/extensionSettings.js'
|
||||
);
|
||||
const extensionConfig =
|
||||
await extensionManager.loadExtensionConfig(extensionPath);
|
||||
|
||||
const mockRequestSetting = vi.fn().mockResolvedValue('second');
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
'MY_VALUE',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
await extensionManager.restartExtension(extension);
|
||||
|
||||
const reloadedExtension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === extensionName)!;
|
||||
expect(reloadedExtension.skills![0].body).toContain('Value is: second');
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
INSTALL_METADATA_FILENAME,
|
||||
recursivelyHydrateStrings,
|
||||
type JsonObject,
|
||||
type VariableContext,
|
||||
} from './extensions/variables.js';
|
||||
import {
|
||||
getEnvContents,
|
||||
@@ -538,12 +539,14 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
extensionId,
|
||||
ExtensionSettingScope.USER,
|
||||
);
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
this.workspaceDir,
|
||||
);
|
||||
if (isWorkspaceTrusted(this.settings).isTrusted) {
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
this.workspaceDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const customEnv = { ...userSettings, ...workspaceSettings };
|
||||
@@ -612,24 +615,63 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
)
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
const hydrationContext: VariableContext = {
|
||||
extensionPath: effectiveExtensionPath,
|
||||
workspacePath: this.workspaceDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
...customEnv,
|
||||
};
|
||||
|
||||
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
if (
|
||||
this.settings.tools.enableHooks &&
|
||||
this.settings.hooksConfig.enabled
|
||||
) {
|
||||
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
|
||||
extensionPath: effectiveExtensionPath,
|
||||
workspacePath: this.workspaceDir,
|
||||
});
|
||||
hooks = await this.loadExtensionHooks(
|
||||
effectiveExtensionPath,
|
||||
hydrationContext,
|
||||
);
|
||||
}
|
||||
|
||||
const skills = await loadSkillsFromDir(
|
||||
// Hydrate hooks with extension settings as environment variables
|
||||
if (hooks && config.settings) {
|
||||
const hookEnv: Record<string, string> = {};
|
||||
for (const setting of config.settings) {
|
||||
const value = customEnv[setting.envVar];
|
||||
if (value !== undefined) {
|
||||
hookEnv[setting.envVar] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(hookEnv).length > 0) {
|
||||
for (const eventName of Object.keys(hooks)) {
|
||||
const eventHooks = hooks[eventName as HookEventName];
|
||||
if (eventHooks) {
|
||||
for (const definition of eventHooks) {
|
||||
for (const hook of definition.hooks) {
|
||||
// Merge existing env with new env vars, giving extension settings precedence.
|
||||
hook.env = { ...hook.env, ...hookEnv };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let skills = await loadSkillsFromDir(
|
||||
path.join(effectiveExtensionPath, 'skills'),
|
||||
);
|
||||
skills = skills.map((skill) =>
|
||||
recursivelyHydrateStrings(skill, hydrationContext),
|
||||
);
|
||||
|
||||
const agentLoadResult = await loadAgentsFromDirectory(
|
||||
path.join(effectiveExtensionPath, 'agents'),
|
||||
);
|
||||
agentLoadResult.agents = agentLoadResult.agents.map((agent) =>
|
||||
recursivelyHydrateStrings(agent, hydrationContext),
|
||||
);
|
||||
|
||||
// Log errors but don't fail the entire extension load
|
||||
for (const error of agentLoadResult.errors) {
|
||||
@@ -671,6 +713,14 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
}
|
||||
|
||||
override async restartExtension(
|
||||
extension: GeminiCLIExtension,
|
||||
): Promise<void> {
|
||||
const extensionDir = extension.path;
|
||||
await this.unloadExtension(extension);
|
||||
await this.loadExtension(extensionDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes `extension` from the list of extensions and stops it if
|
||||
* appropriate.
|
||||
@@ -720,7 +770,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
private async loadExtensionHooks(
|
||||
extensionDir: string,
|
||||
context: { extensionPath: string; workspacePath: string },
|
||||
context: VariableContext,
|
||||
): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> {
|
||||
const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json');
|
||||
|
||||
|
||||
@@ -872,6 +872,7 @@ describe('extension tests', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
settings.hooksConfig.enabled = false;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
|
||||
@@ -398,6 +398,35 @@ describe('extensionSettings', () => {
|
||||
expect(actualContent).toBe('VAR1="a value with spaces"\n');
|
||||
});
|
||||
|
||||
it('should not set sensitive settings if the value is empty during initial setup', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 's1',
|
||||
description: 'd1',
|
||||
envVar: 'SENSITIVE_VAR',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not attempt to clear secrets if keychain is unavailable', async () => {
|
||||
// Arrange
|
||||
const mockIsAvailable = vi.fn().mockResolvedValue(false);
|
||||
@@ -738,5 +767,42 @@ describe('extensionSettings', () => {
|
||||
const lines = actualContent.split('\n').filter((line) => line.length > 0);
|
||||
expect(lines).toHaveLength(3); // Should only have the three variables
|
||||
});
|
||||
|
||||
it('should delete a sensitive setting if the new value is empty', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('VAR2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
// Ensure it doesn't exist first
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.deleteSecret('VAR2');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
// Should complete without error
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function maybePromptForSettings(
|
||||
const nonSensitiveSettings: Record<string, string> = {};
|
||||
for (const setting of settings) {
|
||||
const value = allSettings[setting.envVar];
|
||||
if (value === undefined) {
|
||||
if (value === undefined || value === '') {
|
||||
continue;
|
||||
}
|
||||
if (setting.sensitive) {
|
||||
@@ -207,7 +207,7 @@ export async function updateSetting(
|
||||
settingKey: string,
|
||||
requestSetting: (setting: ExtensionSetting) => Promise<string>,
|
||||
scope: ExtensionSettingScope,
|
||||
workspaceDir?: string,
|
||||
workspaceDir: string,
|
||||
): Promise<void> {
|
||||
const { name: extensionName, settings } = extensionConfig;
|
||||
if (!settings || settings.length === 0) {
|
||||
@@ -230,7 +230,15 @@ export async function updateSetting(
|
||||
);
|
||||
|
||||
if (settingToUpdate.sensitive) {
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
if (newValue) {
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
} else {
|
||||
try {
|
||||
await keychain.deleteSecret(settingToUpdate.envVar);
|
||||
} catch {
|
||||
// Ignore if secret does not exist
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export type JsonValue =
|
||||
| JsonArray;
|
||||
|
||||
export type VariableContext = {
|
||||
[key in keyof typeof VARIABLE_SCHEMA]?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
|
||||
export function validateVariables(
|
||||
@@ -33,7 +33,7 @@ export function validateVariables(
|
||||
) {
|
||||
for (const key in schema) {
|
||||
const definition = schema[key];
|
||||
if (definition.required && !variables[key as keyof VariableContext]) {
|
||||
if (definition.required && !variables[key]) {
|
||||
throw new Error(`Missing required variable: ${key}`);
|
||||
}
|
||||
}
|
||||
@@ -43,30 +43,33 @@ export function hydrateString(str: string, context: VariableContext): string {
|
||||
validateVariables(context, VARIABLE_SCHEMA);
|
||||
const regex = /\${(.*?)}/g;
|
||||
return str.replace(regex, (match, key) =>
|
||||
context[key as keyof VariableContext] == null
|
||||
? match
|
||||
: (context[key as keyof VariableContext] as string),
|
||||
context[key] == null ? match : context[key],
|
||||
);
|
||||
}
|
||||
|
||||
export function recursivelyHydrateStrings(
|
||||
obj: JsonValue,
|
||||
export function recursivelyHydrateStrings<T>(
|
||||
obj: T,
|
||||
values: VariableContext,
|
||||
): JsonValue {
|
||||
): T {
|
||||
if (typeof obj === 'string') {
|
||||
return hydrateString(obj, values);
|
||||
return hydrateString(obj, values) as unknown as T;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => recursivelyHydrateStrings(item, values));
|
||||
return obj.map((item) =>
|
||||
recursivelyHydrateStrings(item, values),
|
||||
) as unknown as T;
|
||||
}
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
const newObj: JsonObject = {};
|
||||
const newObj: Record<string, unknown> = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
newObj[key] = recursivelyHydrateStrings(obj[key], values);
|
||||
newObj[key] = recursivelyHydrateStrings(
|
||||
(obj as Record<string, unknown>)[key],
|
||||
values,
|
||||
);
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
return newObj as T;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -49,12 +49,15 @@ export enum Command {
|
||||
REVERSE_SEARCH = 'history.search.start',
|
||||
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
|
||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
|
||||
REWIND = 'history.rewind',
|
||||
|
||||
// Navigation
|
||||
NAVIGATION_UP = 'nav.up',
|
||||
NAVIGATION_DOWN = 'nav.down',
|
||||
DIALOG_NAVIGATION_UP = 'nav.dialog.up',
|
||||
DIALOG_NAVIGATION_DOWN = 'nav.dialog.down',
|
||||
DIALOG_NEXT = 'nav.dialog.next',
|
||||
DIALOG_PREV = 'nav.dialog.previous',
|
||||
|
||||
// Suggestions & Completions
|
||||
ACCEPT_SUGGESTION = 'suggest.accept',
|
||||
@@ -188,6 +191,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }],
|
||||
[Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }],
|
||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||
[Command.REWIND]: [{ key: 'double escape' }],
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
||||
|
||||
@@ -204,6 +208,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'down', shift: false },
|
||||
{ key: 'j', shift: false },
|
||||
],
|
||||
[Command.DIALOG_NEXT]: [{ key: 'tab', shift: false }],
|
||||
[Command.DIALOG_PREV]: [{ key: 'tab', shift: true }],
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
|
||||
@@ -251,7 +257,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
|
||||
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
|
||||
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
|
||||
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
|
||||
[Command.SHOW_MORE_LINES]: [
|
||||
{ key: 'o', ctrl: true },
|
||||
{ key: 's', ctrl: true },
|
||||
],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
@@ -317,6 +326,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.REVERSE_SEARCH,
|
||||
Command.SUBMIT_REVERSE_SEARCH,
|
||||
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
|
||||
Command.REWIND,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -326,6 +336,8 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.NAVIGATION_DOWN,
|
||||
Command.DIALOG_NAVIGATION_UP,
|
||||
Command.DIALOG_NAVIGATION_DOWN,
|
||||
Command.DIALOG_NEXT,
|
||||
Command.DIALOG_PREV,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -413,12 +425,15 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
||||
'Accept a suggestion while reverse searching.',
|
||||
[Command.REWIND]: 'Browse and rewind previous interactions.',
|
||||
|
||||
// Navigation
|
||||
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
|
||||
[Command.NAVIGATION_DOWN]: 'Move selection down in lists.',
|
||||
[Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.',
|
||||
[Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.',
|
||||
[Command.DIALOG_NEXT]: 'Move to the next item or question in a dialog.',
|
||||
[Command.DIALOG_PREV]: 'Move to the previous item or question in a dialog.',
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export {
|
||||
McpServerEnablementManager,
|
||||
canLoadServer,
|
||||
normalizeServerId,
|
||||
isInSettingsList,
|
||||
type McpServerEnablementState,
|
||||
type McpServerEnablementConfig,
|
||||
type McpServerDisplayState,
|
||||
type EnablementCallbacks,
|
||||
type ServerLoadResult,
|
||||
} from './mcpServerEnablement.js';
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Storage: {
|
||||
...actual.Storage,
|
||||
getGlobalGeminiDir: () => '/virtual-home/.gemini',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
McpServerEnablementManager,
|
||||
canLoadServer,
|
||||
normalizeServerId,
|
||||
isInSettingsList,
|
||||
type EnablementCallbacks,
|
||||
} from './mcpServerEnablement.js';
|
||||
|
||||
let inMemoryFs: Record<string, string> = {};
|
||||
|
||||
function createMockEnablement(
|
||||
sessionDisabled: boolean,
|
||||
fileEnabled: boolean,
|
||||
): EnablementCallbacks {
|
||||
return {
|
||||
isSessionDisabled: () => sessionDisabled,
|
||||
isFileEnabled: () => Promise.resolve(fileEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
function setupFsMocks(): void {
|
||||
vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => {
|
||||
const content = inMemoryFs[filePath.toString()];
|
||||
if (content === undefined) {
|
||||
const error = new Error(`ENOENT: ${filePath}`);
|
||||
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return content;
|
||||
});
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath, data) => {
|
||||
inMemoryFs[filePath.toString()] = data.toString();
|
||||
});
|
||||
vi.spyOn(fs, 'mkdir').mockImplementation(async () => undefined);
|
||||
}
|
||||
|
||||
describe('McpServerEnablementManager', () => {
|
||||
let manager: McpServerEnablementManager;
|
||||
|
||||
beforeEach(() => {
|
||||
inMemoryFs = {};
|
||||
setupFsMocks();
|
||||
McpServerEnablementManager.resetInstance();
|
||||
manager = McpServerEnablementManager.getInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
McpServerEnablementManager.resetInstance();
|
||||
});
|
||||
|
||||
it('should enable/disable servers with persistence', async () => {
|
||||
expect(await manager.isFileEnabled('server')).toBe(true);
|
||||
await manager.disable('server');
|
||||
expect(await manager.isFileEnabled('server')).toBe(false);
|
||||
await manager.enable('server');
|
||||
expect(await manager.isFileEnabled('server')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle session disable separately', async () => {
|
||||
manager.disableForSession('server');
|
||||
expect(manager.isSessionDisabled('server')).toBe(true);
|
||||
expect(await manager.isFileEnabled('server')).toBe(true);
|
||||
expect(await manager.isEffectivelyEnabled('server')).toBe(false);
|
||||
manager.clearSessionDisable('server');
|
||||
expect(await manager.isEffectivelyEnabled('server')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', async () => {
|
||||
await manager.disable('PlayWright');
|
||||
expect(await manager.isFileEnabled('playwright')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct display state', async () => {
|
||||
await manager.disable('file-disabled');
|
||||
manager.disableForSession('session-disabled');
|
||||
|
||||
expect(await manager.getDisplayState('enabled')).toEqual({
|
||||
enabled: true,
|
||||
isSessionDisabled: false,
|
||||
isPersistentDisabled: false,
|
||||
});
|
||||
expect(
|
||||
(await manager.getDisplayState('file-disabled')).isPersistentDisabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await manager.getDisplayState('session-disabled')).isSessionDisabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should share session state across getInstance calls', () => {
|
||||
const instance1 = McpServerEnablementManager.getInstance();
|
||||
const instance2 = McpServerEnablementManager.getInstance();
|
||||
|
||||
instance1.disableForSession('test-server');
|
||||
|
||||
expect(instance2.isSessionDisabled('test-server')).toBe(true);
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canLoadServer', () => {
|
||||
it('blocks when admin has disabled MCP', async () => {
|
||||
const result = await canLoadServer('s', { adminMcpEnabled: false });
|
||||
expect(result.blockType).toBe('admin');
|
||||
});
|
||||
|
||||
it('blocks when server is not in allowlist', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
allowedList: ['other'],
|
||||
});
|
||||
expect(result.blockType).toBe('allowlist');
|
||||
});
|
||||
|
||||
it('blocks when server is in excludelist', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
excludedList: ['s'],
|
||||
});
|
||||
expect(result.blockType).toBe('excludelist');
|
||||
});
|
||||
|
||||
it('blocks when server is session-disabled', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
enablement: createMockEnablement(true, true),
|
||||
});
|
||||
expect(result.blockType).toBe('session');
|
||||
});
|
||||
|
||||
it('blocks when server is file-disabled', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
enablement: createMockEnablement(false, false),
|
||||
});
|
||||
expect(result.blockType).toBe('enablement');
|
||||
});
|
||||
|
||||
it('allows when admin MCP is enabled and no restrictions', async () => {
|
||||
const result = await canLoadServer('s', { adminMcpEnabled: true });
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('allows when server passes all checks', async () => {
|
||||
const result = await canLoadServer('s', {
|
||||
adminMcpEnabled: true,
|
||||
allowedList: ['s'],
|
||||
enablement: createMockEnablement(false, true),
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper functions', () => {
|
||||
it('normalizeServerId lowercases and trims', () => {
|
||||
expect(normalizeServerId(' PlayWright ')).toBe('playwright');
|
||||
});
|
||||
|
||||
it('isInSettingsList supports ext: backward compat', () => {
|
||||
expect(isInSettingsList('playwright', ['playwright']).found).toBe(true);
|
||||
expect(isInSettingsList('ext:github:mcp', ['mcp']).found).toBe(true);
|
||||
expect(
|
||||
isInSettingsList('ext:github:mcp', ['mcp']).deprecationWarning,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Storage, coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Stored in JSON file - represents persistent enablement state.
|
||||
*/
|
||||
export interface McpServerEnablementState {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* File config format - map of server ID to enablement state.
|
||||
*/
|
||||
export interface McpServerEnablementConfig {
|
||||
[serverId: string]: McpServerEnablementState;
|
||||
}
|
||||
|
||||
/**
|
||||
* For UI display - combines file and session state.
|
||||
*/
|
||||
export interface McpServerDisplayState {
|
||||
/** Effective state (considering session override) */
|
||||
enabled: boolean;
|
||||
/** True if disabled via --session flag */
|
||||
isSessionDisabled: boolean;
|
||||
/** True if disabled in file */
|
||||
isPersistentDisabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback types for enablement checks (passed from CLI to core).
|
||||
*/
|
||||
export interface EnablementCallbacks {
|
||||
isSessionDisabled: (serverId: string) => boolean;
|
||||
isFileEnabled: (serverId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of canLoadServer check.
|
||||
*/
|
||||
export interface ServerLoadResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
blockType?: 'admin' | 'allowlist' | 'excludelist' | 'session' | 'enablement';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a server ID to canonical lowercase form.
|
||||
*/
|
||||
export function normalizeServerId(serverId: string): string {
|
||||
return serverId.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a server ID is in a settings list (with backward compatibility).
|
||||
* Handles case-insensitive matching and plain name fallback for ext: servers.
|
||||
*/
|
||||
export function isInSettingsList(
|
||||
serverId: string,
|
||||
list: string[],
|
||||
): { found: boolean; deprecationWarning?: string } {
|
||||
const normalizedId = normalizeServerId(serverId);
|
||||
const normalizedList = list.map(normalizeServerId);
|
||||
|
||||
// Exact canonical match
|
||||
if (normalizedList.includes(normalizedId)) {
|
||||
return { found: true };
|
||||
}
|
||||
|
||||
// Backward compat: for ext: servers, check if plain name matches
|
||||
if (normalizedId.startsWith('ext:')) {
|
||||
const plainName = normalizedId.split(':').pop();
|
||||
if (plainName && normalizedList.includes(plainName)) {
|
||||
return {
|
||||
found: true,
|
||||
deprecationWarning:
|
||||
`Settings reference '${plainName}' matches extension server '${serverId}'. ` +
|
||||
`Update your settings to use the full identifier '${serverId}' instead.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for whether a server can be loaded.
|
||||
* Used by: isAllowedMcpServer(), connectServer(), CLI handlers, slash handlers.
|
||||
*
|
||||
* Uses callbacks instead of direct enablementManager reference to keep
|
||||
* packages/core independent of packages/cli.
|
||||
*/
|
||||
export async function canLoadServer(
|
||||
serverId: string,
|
||||
config: {
|
||||
adminMcpEnabled: boolean;
|
||||
allowedList?: string[];
|
||||
excludedList?: string[];
|
||||
enablement?: EnablementCallbacks;
|
||||
},
|
||||
): Promise<ServerLoadResult> {
|
||||
const normalizedId = normalizeServerId(serverId);
|
||||
|
||||
// 1. Admin kill switch
|
||||
if (!config.adminMcpEnabled) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
'MCP servers are disabled by administrator. Check admin settings or contact your admin.',
|
||||
blockType: 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Allowlist check
|
||||
if (config.allowedList && config.allowedList.length > 0) {
|
||||
const { found, deprecationWarning } = isInSettingsList(
|
||||
normalizedId,
|
||||
config.allowedList,
|
||||
);
|
||||
if (deprecationWarning) {
|
||||
coreEvents.emitFeedback('warning', deprecationWarning);
|
||||
}
|
||||
if (!found) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Server '${serverId}' is not in mcp.allowed list. Add it to settings.json mcp.allowed array to enable.`,
|
||||
blockType: 'allowlist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Excludelist check
|
||||
if (config.excludedList) {
|
||||
const { found, deprecationWarning } = isInSettingsList(
|
||||
normalizedId,
|
||||
config.excludedList,
|
||||
);
|
||||
if (deprecationWarning) {
|
||||
coreEvents.emitFeedback('warning', deprecationWarning);
|
||||
}
|
||||
if (found) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Server '${serverId}' is blocked by mcp.excluded. Remove it from settings.json mcp.excluded array to enable.`,
|
||||
blockType: 'excludelist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Session disable check (before file-based enablement)
|
||||
if (config.enablement?.isSessionDisabled(normalizedId)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Server '${serverId}' is disabled for this session. Run 'gemini mcp enable ${serverId} --session' to clear.`,
|
||||
blockType: 'session',
|
||||
};
|
||||
}
|
||||
|
||||
// 5. File-based enablement check
|
||||
if (
|
||||
config.enablement &&
|
||||
!(await config.enablement.isFileEnabled(normalizedId))
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Server '${serverId}' is disabled. Run 'gemini mcp enable ${serverId}' to enable.`,
|
||||
blockType: 'enablement',
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const MCP_ENABLEMENT_FILENAME = 'mcp-server-enablement.json';
|
||||
|
||||
/**
|
||||
* McpServerEnablementManager
|
||||
*
|
||||
* Manages the enabled/disabled state of MCP servers.
|
||||
* Uses a simplified format compared to ExtensionEnablementManager.
|
||||
* Supports both persistent (file) and session-only (in-memory) states.
|
||||
*
|
||||
* NOTE: Use getInstance() to get the singleton instance. This ensures
|
||||
* session state (sessionDisabled Set) is shared across all code paths.
|
||||
*/
|
||||
export class McpServerEnablementManager {
|
||||
private static instance: McpServerEnablementManager | null = null;
|
||||
|
||||
private readonly configFilePath: string;
|
||||
private readonly configDir: string;
|
||||
private readonly sessionDisabled = new Set<string>();
|
||||
|
||||
/**
|
||||
* Get the singleton instance.
|
||||
*/
|
||||
static getInstance(): McpServerEnablementManager {
|
||||
if (!McpServerEnablementManager.instance) {
|
||||
McpServerEnablementManager.instance = new McpServerEnablementManager();
|
||||
}
|
||||
return McpServerEnablementManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for testing only).
|
||||
*/
|
||||
static resetInstance(): void {
|
||||
McpServerEnablementManager.instance = null;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.configDir = Storage.getGlobalGeminiDir();
|
||||
this.configFilePath = path.join(this.configDir, MCP_ENABLEMENT_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server is enabled in FILE (persistent config only).
|
||||
* Does NOT include session state.
|
||||
*/
|
||||
async isFileEnabled(serverName: string): Promise<boolean> {
|
||||
const config = await this.readConfig();
|
||||
const state = config[normalizeServerId(serverName)];
|
||||
return state?.enabled ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server is session-disabled.
|
||||
*/
|
||||
isSessionDisabled(serverName: string): boolean {
|
||||
return this.sessionDisabled.has(normalizeServerId(serverName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check effective enabled state (combines file + session).
|
||||
* Convenience method; canLoadServer() uses separate callbacks for granular blockType.
|
||||
*/
|
||||
async isEffectivelyEnabled(serverName: string): Promise<boolean> {
|
||||
if (this.isSessionDisabled(serverName)) {
|
||||
return false;
|
||||
}
|
||||
return this.isFileEnabled(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a server persistently.
|
||||
* Removes the server from config file (defaults to enabled).
|
||||
*/
|
||||
async enable(serverName: string): Promise<void> {
|
||||
const normalizedName = normalizeServerId(serverName);
|
||||
const config = await this.readConfig();
|
||||
|
||||
if (normalizedName in config) {
|
||||
delete config[normalizedName];
|
||||
await this.writeConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a server persistently.
|
||||
* Adds server to config file with enabled: false.
|
||||
*/
|
||||
async disable(serverName: string): Promise<void> {
|
||||
const config = await this.readConfig();
|
||||
config[normalizeServerId(serverName)] = { enabled: false };
|
||||
await this.writeConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a server for current session only (in-memory).
|
||||
*/
|
||||
disableForSession(serverName: string): void {
|
||||
this.sessionDisabled.add(normalizeServerId(serverName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session disable for a server.
|
||||
*/
|
||||
clearSessionDisable(serverName: string): void {
|
||||
this.sessionDisabled.delete(normalizeServerId(serverName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display state for a specific server (for UI).
|
||||
*/
|
||||
async getDisplayState(serverName: string): Promise<McpServerDisplayState> {
|
||||
const isSessionDisabled = this.isSessionDisabled(serverName);
|
||||
const isPersistentDisabled = !(await this.isFileEnabled(serverName));
|
||||
|
||||
return {
|
||||
enabled: !isSessionDisabled && !isPersistentDisabled,
|
||||
isSessionDisabled,
|
||||
isPersistentDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all display states (for UI listing).
|
||||
*/
|
||||
async getAllDisplayStates(
|
||||
serverIds: string[],
|
||||
): Promise<Record<string, McpServerDisplayState>> {
|
||||
const result: Record<string, McpServerDisplayState> = {};
|
||||
for (const serverId of serverIds) {
|
||||
result[normalizeServerId(serverId)] =
|
||||
await this.getDisplayState(serverId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enablement callbacks for passing to core.
|
||||
*/
|
||||
getEnablementCallbacks(): EnablementCallbacks {
|
||||
return {
|
||||
isSessionDisabled: (id) => this.isSessionDisabled(id),
|
||||
isFileEnabled: (id) => this.isFileEnabled(id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-enable any disabled MCP servers by name.
|
||||
* Returns server names that were actually re-enabled.
|
||||
*/
|
||||
async autoEnableServers(serverNames: string[]): Promise<string[]> {
|
||||
const enabledServers: string[] = [];
|
||||
|
||||
for (const serverName of serverNames) {
|
||||
const normalizedName = normalizeServerId(serverName);
|
||||
const state = await this.getDisplayState(normalizedName);
|
||||
|
||||
let wasDisabled = false;
|
||||
if (state.isPersistentDisabled) {
|
||||
await this.enable(normalizedName);
|
||||
wasDisabled = true;
|
||||
}
|
||||
if (state.isSessionDisabled) {
|
||||
this.clearSessionDisable(normalizedName);
|
||||
wasDisabled = true;
|
||||
}
|
||||
|
||||
if (wasDisabled) {
|
||||
enabledServers.push(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
return enabledServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read config from file asynchronously.
|
||||
*/
|
||||
private async readConfig(): Promise<McpServerEnablementConfig> {
|
||||
try {
|
||||
const content = await fs.readFile(this.configFilePath, 'utf-8');
|
||||
return JSON.parse(content) as McpServerEnablementConfig;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'Failed to read MCP server enablement config.',
|
||||
error,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write config to file asynchronously.
|
||||
*/
|
||||
private async writeConfig(config: McpServerEnablementConfig): Promise<void> {
|
||||
await fs.mkdir(this.configDir, { recursive: true });
|
||||
await fs.writeFile(this.configFilePath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,117 @@ describe('Policy Engine Integration Tests', () => {
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow write_file to plans directory in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Valid plan file path (64-char hex hash, .md extension, safe filename)
|
||||
const validPlanPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: validPlanPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Valid plan with underscore in filename
|
||||
const validPlanPath2 =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: validPlanPath2 } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should deny write_file outside plans directory in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Write to workspace (not plans dir) should be denied
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: '/project/src/file.ts' } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Write to plans dir but wrong extension should be denied
|
||||
const wrongExtPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: wrongExtPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Path traversal attempt should be denied (filename contains /)
|
||||
const traversalPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: traversalPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Invalid hash length should be denied
|
||||
const shortHashPath = '/home/user/.gemini/tmp/abc123/plans/plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: shortHashPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should deny write_file to subdirectories in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Write to subdirectory should be denied
|
||||
const subdirPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: subdirPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
|
||||
@@ -2012,6 +2012,56 @@ describe('Settings Loading and Merging', () => {
|
||||
// Merged should also reflect it (system overrides defaults, but both are migrated)
|
||||
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
|
||||
});
|
||||
|
||||
it('should migrate experimental agent settings to agents overrides', () => {
|
||||
const userSettingsContent = {
|
||||
experimental: {
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
maxNumTurns: 15,
|
||||
maxTimeMinutes: 5,
|
||||
thinkingBudget: 16384,
|
||||
model: 'gemini-1.5-pro',
|
||||
},
|
||||
cliHelpAgentSettings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify migration to agents.overrides
|
||||
expect(settings.user.settings.agents?.overrides).toMatchObject({
|
||||
codebase_investigator: {
|
||||
enabled: true,
|
||||
runConfig: {
|
||||
maxTurns: 15,
|
||||
maxTimeMinutes: 5,
|
||||
},
|
||||
modelConfig: {
|
||||
model: 'gemini-1.5-pro',
|
||||
generateContentConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 16384,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cli_help: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSettings', () => {
|
||||
@@ -2165,8 +2215,8 @@ describe('Settings Loading and Merging', () => {
|
||||
// and missing properties revert to schema defaults.
|
||||
loadedSettings.setRemoteAdminSettings({ secureModeEnabled: false });
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); // Reverts to default: true
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); // Reverts to default: true
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); // Defaulting to false if missing
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Defaulting to false if missing
|
||||
});
|
||||
|
||||
it('should correctly handle undefined remote admin settings', () => {
|
||||
@@ -2226,10 +2276,10 @@ describe('Settings Loading and Merging', () => {
|
||||
secureModeEnabled: true,
|
||||
});
|
||||
|
||||
// Verify secureModeEnabled is updated, others remain defaults
|
||||
// Verify secureModeEnabled is updated, others default to false
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Set remote settings with only mcpSetting.mcpEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
@@ -2239,7 +2289,7 @@ describe('Settings Loading and Merging', () => {
|
||||
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled reverts to default:false)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Set remote settings with only cliFeatureSetting.extensionsSetting.extensionsEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
@@ -2248,7 +2298,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
// Verify extensionsEnabled is updated, others remain defaults
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -2268,6 +2318,62 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
expect(loadedSettings.merged.admin.skills?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should default mcp.enabled to false if mcpSetting is present but mcpEnabled is undefined', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should default extensions.enabled to false if extensionsSetting is present but extensionsEnabled is undefined', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: {},
|
||||
},
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should force secureModeEnabled to false if undefined, overriding schema defaults', () => {
|
||||
// Mock schema to have secureModeEnabled default to true to verify the override
|
||||
const originalSchema = getSettingsSchema();
|
||||
const modifiedSchema = JSON.parse(JSON.stringify(originalSchema));
|
||||
if (modifiedSchema.admin?.properties?.secureModeEnabled) {
|
||||
modifiedSchema.admin.properties.secureModeEnabled.default = true;
|
||||
}
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(modifiedSchema);
|
||||
|
||||
try {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(() => '{}');
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Pass a non-empty object that doesn't have secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
|
||||
// It should be forced to false by the logic, overriding the mock default of true
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
} finally {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(originalSchema);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle completely empty remote admin settings response', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({});
|
||||
|
||||
// Should default to schema defaults (standard defaults)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultsFromSchema', () => {
|
||||
|
||||
@@ -350,18 +350,17 @@ export class LoadedSettings {
|
||||
const admin: Settings['admin'] = {};
|
||||
const { secureModeEnabled, mcpSetting, cliFeatureSetting } = remoteSettings;
|
||||
|
||||
if (secureModeEnabled !== undefined) {
|
||||
admin.secureModeEnabled = secureModeEnabled;
|
||||
if (Object.keys(remoteSettings).length === 0) {
|
||||
this._remoteAdminSettings = { admin };
|
||||
this._merged = this.computeMergedSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mcpSetting?.mcpEnabled !== undefined) {
|
||||
admin.mcp = { enabled: mcpSetting.mcpEnabled };
|
||||
}
|
||||
|
||||
const extensionsSetting = cliFeatureSetting?.extensionsSetting;
|
||||
if (extensionsSetting?.extensionsEnabled !== undefined) {
|
||||
admin.extensions = { enabled: extensionsSetting.extensionsEnabled };
|
||||
}
|
||||
admin.secureModeEnabled = secureModeEnabled ?? false;
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
|
||||
admin.extensions = {
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
|
||||
};
|
||||
|
||||
if (cliFeatureSetting?.advancedFeaturesEnabled !== undefined) {
|
||||
admin.skills = { enabled: cliFeatureSetting.advancedFeaturesEnabled };
|
||||
@@ -804,6 +803,14 @@ export function migrateDeprecatedSettings(
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
anyModified ||= migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
);
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
@@ -852,3 +859,100 @@ export function saveModelChange(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateExperimentalSettings(
|
||||
settings: Settings,
|
||||
loadedSettings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
removeDeprecated: boolean,
|
||||
): boolean {
|
||||
const experimentalSettings = settings.experimental as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (experimentalSettings) {
|
||||
const agentsSettings = {
|
||||
...(settings.agents as Record<string, unknown> | undefined),
|
||||
};
|
||||
const agentsOverrides = {
|
||||
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
|
||||
};
|
||||
let modified = false;
|
||||
|
||||
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
|
||||
if (experimentalSettings['codebaseInvestigatorSettings']) {
|
||||
const old = experimentalSettings[
|
||||
'codebaseInvestigatorSettings'
|
||||
] as Record<string, unknown>;
|
||||
const override = {
|
||||
...(agentsOverrides['codebase_investigator'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
|
||||
const runConfig = {
|
||||
...(override['runConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['maxNumTurns'] !== undefined)
|
||||
runConfig['maxTurns'] = old['maxNumTurns'];
|
||||
if (old['maxTimeMinutes'] !== undefined)
|
||||
runConfig['maxTimeMinutes'] = old['maxTimeMinutes'];
|
||||
if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig;
|
||||
|
||||
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
|
||||
const modelConfig = {
|
||||
...(override['modelConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
|
||||
if (old['thinkingBudget'] !== undefined) {
|
||||
const generateContentConfig = {
|
||||
...(modelConfig['generateContentConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
const thinkingConfig = {
|
||||
...(generateContentConfig['thinkingConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
thinkingConfig['thinkingBudget'] = old['thinkingBudget'];
|
||||
generateContentConfig['thinkingConfig'] = thinkingConfig;
|
||||
modelConfig['generateContentConfig'] = generateContentConfig;
|
||||
}
|
||||
override['modelConfig'] = modelConfig;
|
||||
}
|
||||
|
||||
agentsOverrides['codebase_investigator'] = override;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
|
||||
if (experimentalSettings['cliHelpAgentSettings']) {
|
||||
const old = experimentalSettings['cliHelpAgentSettings'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const override = {
|
||||
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
agentsOverrides['cli_help'] = override;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
agentsSettings['overrides'] = agentsOverrides;
|
||||
loadedSettings.setValue(scope, 'agents', agentsSettings);
|
||||
|
||||
if (removeDeprecated) {
|
||||
const newExperimental = { ...experimentalSettings };
|
||||
delete newExperimental['codebaseInvestigatorSettings'];
|
||||
delete newExperimental['cliHelpAgentSettings'];
|
||||
loadedSettings.setValue(scope, 'experimental', newExperimental);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,14 @@ describe('SettingsSchema', () => {
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.enableRecursiveFileSearch,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.customIgnoreFilePaths,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.customIgnoreFilePaths.type,
|
||||
).toBe('array');
|
||||
});
|
||||
|
||||
it('should have unique categories', () => {
|
||||
@@ -387,7 +395,7 @@ describe('SettingsSchema', () => {
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CustomTheme } from '../ui/themes/theme.js';
|
||||
import type { SessionRetentionSettings } from './settings.js';
|
||||
@@ -527,15 +526,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Show the model name in the chat for each model turn.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useFullWidth: {
|
||||
type: 'boolean',
|
||||
label: 'Use Full Width',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Use the entire width of the terminal for output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useAlternateBuffer: {
|
||||
type: 'boolean',
|
||||
label: 'Use Alternate Screen Buffer',
|
||||
@@ -546,6 +536,15 @@ const SETTINGS_SCHEMA = {
|
||||
'Use an alternate screen buffer for the UI, preserving shell history.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useBackgroundColor: {
|
||||
type: 'boolean',
|
||||
label: 'Use Background Color',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Whether to use background colors in the UI.',
|
||||
showInDialog: true,
|
||||
},
|
||||
incrementalRendering: {
|
||||
type: 'boolean',
|
||||
label: 'Incremental Rendering',
|
||||
@@ -556,6 +555,15 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showSpinner: {
|
||||
type: 'boolean',
|
||||
label: 'Show Spinner',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Show the spinner during operations.',
|
||||
showInDialog: true,
|
||||
},
|
||||
customWittyPhrases: {
|
||||
type: 'array',
|
||||
label: 'Custom Witty Phrases',
|
||||
@@ -924,6 +932,18 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable fuzzy search when searching for files.',
|
||||
showInDialog: true,
|
||||
},
|
||||
customIgnoreFilePaths: {
|
||||
type: 'array',
|
||||
label: 'Custom Ignore File Paths',
|
||||
category: 'Context',
|
||||
requiresRestart: true,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one.',
|
||||
showInDialog: true,
|
||||
items: { type: 'string' },
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1024,6 +1044,24 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: 'default',
|
||||
description: oneLine`
|
||||
The default approval mode for tool execution.
|
||||
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
|
||||
and 'plan' is read-only mode. 'yolo' is not supported yet.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'auto_edit', label: 'Auto Edit' },
|
||||
{ value: 'plan', label: 'Plan' },
|
||||
],
|
||||
},
|
||||
core: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
@@ -1429,7 +1467,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Event Driven Scheduler',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enables event-driven scheduler within the CLI session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
@@ -1454,72 +1492,12 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
skills: {
|
||||
type: 'boolean',
|
||||
label: 'Agent Skills',
|
||||
label: 'Agent Skills (Deprecated)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable Agent Skills (experimental).',
|
||||
showInDialog: true,
|
||||
},
|
||||
codebaseInvestigatorSettings: {
|
||||
type: 'object',
|
||||
label: 'Codebase Investigator Settings',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Configuration for Codebase Investigator.',
|
||||
description: '[Deprecated] Enable Agent Skills (experimental).',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Codebase Investigator',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable the Codebase Investigator agent.',
|
||||
showInDialog: true,
|
||||
},
|
||||
maxNumTurns: {
|
||||
type: 'number',
|
||||
label: 'Codebase Investigator Max Num Turns',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 10,
|
||||
description:
|
||||
'Maximum number of turns for the Codebase Investigator agent.',
|
||||
showInDialog: true,
|
||||
},
|
||||
maxTimeMinutes: {
|
||||
type: 'number',
|
||||
label: 'Max Time (Minutes)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 3,
|
||||
description:
|
||||
'Maximum time for the Codebase Investigator agent (in minutes).',
|
||||
showInDialog: false,
|
||||
},
|
||||
thinkingBudget: {
|
||||
type: 'number',
|
||||
label: 'Thinking Budget',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: 8192,
|
||||
description:
|
||||
'The thinking budget for the Codebase Investigator agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
model: {
|
||||
type: 'string',
|
||||
label: 'Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: GEMINI_MODEL_ALIAS_AUTO,
|
||||
description:
|
||||
'The model to use for the Codebase Investigator agent.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
useOSC52Paste: {
|
||||
type: 'boolean',
|
||||
@@ -1531,26 +1509,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions).',
|
||||
showInDialog: true,
|
||||
},
|
||||
cliHelpAgentSettings: {
|
||||
type: 'object',
|
||||
label: 'CLI Help Agent Settings',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Configuration for CLI Help Agent.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable CLI Help Agent',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable the CLI Help Agent.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
type: 'boolean',
|
||||
label: 'Plan',
|
||||
@@ -1615,7 +1573,6 @@ const SETTINGS_SCHEMA = {
|
||||
default: true,
|
||||
description: 'Enable Agent Skills.',
|
||||
showInDialog: true,
|
||||
ignoreInDocs: true,
|
||||
},
|
||||
disabled: {
|
||||
type: 'array',
|
||||
@@ -1645,11 +1602,11 @@ const SETTINGS_SCHEMA = {
|
||||
type: 'boolean',
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
disabled: {
|
||||
type: 'array',
|
||||
|
||||
@@ -155,8 +155,12 @@ describe('Settings Repro', () => {
|
||||
experimental: {
|
||||
useModelRouter: false,
|
||||
enableSubagents: false,
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
codebase_investigator: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -6,18 +6,20 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Config,
|
||||
ValidationRequiredError,
|
||||
AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
AuthType: {
|
||||
OAUTH: 'oauth',
|
||||
},
|
||||
getErrorMessage: (e: unknown) => (e as Error).message,
|
||||
}));
|
||||
|
||||
const AuthType = {
|
||||
OAUTH: 'oauth',
|
||||
} as const;
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getErrorMessage: (e: unknown) => (e as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
describe('auth', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -37,10 +39,12 @@ describe('auth', () => {
|
||||
it('should return null on successful auth', async () => {
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error message on failed auth', async () => {
|
||||
@@ -48,9 +52,25 @@ describe('auth', () => {
|
||||
vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error);
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toBe('Failed to login. Message: Auth failed');
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null if refreshAuth throws ValidationRequiredError', async () => {
|
||||
vi.mocked(mockConfig.refreshAuth).mockRejectedValue(
|
||||
new ValidationRequiredError('Validation required'),
|
||||
);
|
||||
const result = await performInitialAuth(
|
||||
mockConfig,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type AuthType,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
ValidationRequiredError,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,11 @@ export async function performInitialAuth(
|
||||
// The console.log is intentionally left out here.
|
||||
// We can add a dedicated startup message later if needed.
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationRequiredError) {
|
||||
// Don't treat validation required as a fatal auth error during startup.
|
||||
// This allows the React UI to load and show the ValidationDialog.
|
||||
return null;
|
||||
}
|
||||
return `Failed to login. Message: ${getErrorMessage(e)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1135,6 +1135,8 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
|
||||
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
|
||||
@@ -61,6 +61,8 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
getVersion,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type FetchAdminControlsResponse,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
@@ -324,6 +326,12 @@ export async function main() {
|
||||
const argv = await parseArguments(settings.merged);
|
||||
parseArgsHandle?.end();
|
||||
|
||||
if (argv.startupMessages) {
|
||||
argv.startupMessages.forEach((msg) => {
|
||||
coreEvents.emitFeedback('info', msg);
|
||||
});
|
||||
}
|
||||
|
||||
// Check for invalid input combinations early to prevent crashes
|
||||
if (argv.promptInteractive && !process.stdin.isTTY) {
|
||||
writeToStderr(
|
||||
@@ -373,6 +381,7 @@ export async function main() {
|
||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||
// the sandbox because the sandbox will interfere with the Oauth2 web
|
||||
// redirect.
|
||||
let initialAuthFailed = false;
|
||||
if (!settings.merged.security.auth.useExternal) {
|
||||
try {
|
||||
if (
|
||||
@@ -399,9 +408,19 @@ export async function main() {
|
||||
await partialConfig.refreshAuth(authType);
|
||||
}
|
||||
} catch (err) {
|
||||
debugLogger.error('Error authenticating:', err);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
|
||||
if (err instanceof ValidationCancelledError) {
|
||||
// User cancelled verification, exit immediately.
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
// If validation is required, we don't treat it as a fatal failure.
|
||||
// We allow the app to start, and the React-based ValidationDialog
|
||||
// will handle it.
|
||||
if (!(err instanceof ValidationRequiredError)) {
|
||||
debugLogger.error('Error authenticating:', err);
|
||||
initialAuthFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +446,10 @@ export async function main() {
|
||||
// another way to decouple refreshAuth from requiring a config.
|
||||
|
||||
if (sandboxConfig) {
|
||||
if (initialAuthFailed) {
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
|
||||
}
|
||||
let stdinData = '';
|
||||
if (!process.stdin.isTTY) {
|
||||
stdinData = await readStdin();
|
||||
@@ -664,9 +687,8 @@ export async function main() {
|
||||
const additionalContext = result.getAdditionalContext();
|
||||
if (additionalContext) {
|
||||
// Prepend context to input (System Context -> Stdin -> Question)
|
||||
input = input
|
||||
? `${additionalContext}\n\n${input}`
|
||||
: additionalContext;
|
||||
const wrappedContext = `<hook_context>${additionalContext}</hook_context>`;
|
||||
input = input ? `${wrappedContext}\n\n${input}` : wrappedContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -728,6 +750,7 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
const windowTitle = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: title,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
UserFeedbackPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
executeToolCall,
|
||||
ToolErrorType,
|
||||
GeminiEventType,
|
||||
OutputFormat,
|
||||
@@ -48,6 +47,8 @@ const mockCoreEvents = vi.hoisted(() => ({
|
||||
drainBacklogs: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSchedulerSchedule = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -61,7 +62,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
|
||||
return {
|
||||
...original,
|
||||
executeToolCall: vi.fn(),
|
||||
Scheduler: class {
|
||||
schedule = mockSchedulerSchedule;
|
||||
cancelAll = vi.fn();
|
||||
},
|
||||
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
uiTelemetryService: {
|
||||
@@ -85,12 +89,12 @@ vi.mock('./services/CommandService.js', () => ({
|
||||
|
||||
vi.mock('./services/FileCommandLoader.js');
|
||||
vi.mock('./services/McpPromptLoader.js');
|
||||
vi.mock('./services/BuiltinCommandLoader.js');
|
||||
|
||||
describe('runNonInteractive', () => {
|
||||
let mockConfig: Config;
|
||||
let mockSettings: LoadedSettings;
|
||||
let mockToolRegistry: ToolRegistry;
|
||||
let mockCoreExecuteToolCall: Mock;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
let processStdoutSpy: MockInstance;
|
||||
let processStderrSpy: MockInstance;
|
||||
@@ -121,7 +125,7 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
|
||||
mockSchedulerSchedule.mockReset();
|
||||
|
||||
mockCommandServiceCreate.mockResolvedValue({
|
||||
getCommands: mockGetCommands,
|
||||
@@ -157,6 +161,11 @@ describe('runNonInteractive', () => {
|
||||
|
||||
mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
}),
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(10),
|
||||
@@ -262,25 +271,27 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
const toolResponse: Part[] = [{ text: 'Tool response' }];
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-2',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-2',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||
@@ -303,9 +314,8 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ name: 'testTool' })],
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
@@ -334,16 +344,18 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
|
||||
// 2. Mock the execution of the tools. We just need them to succeed.
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: toolCallEvent.value, // This is generic enough for both calls
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [],
|
||||
callId: 'mock-tool',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: toolCallEvent.value, // This is generic enough for both calls
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [],
|
||||
callId: 'mock-tool',
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
// 3. Define the sequence of events streamed from the mock model.
|
||||
// Turn 1: Model outputs text, then requests a tool call.
|
||||
@@ -384,7 +396,7 @@ describe('runNonInteractive', () => {
|
||||
expect(getWrittenOutput()).toMatchSnapshot();
|
||||
|
||||
// Also verify the tools were called as expected.
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(2);
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle error during tool execution and should send error back to the model', async () => {
|
||||
@@ -398,34 +410,36 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-3',
|
||||
},
|
||||
};
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'errorTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-3',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
error: new Error('Execution failed'),
|
||||
errorType: ToolErrorType.EXECUTION_FAILED,
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'errorTool',
|
||||
response: {
|
||||
output: 'Error: Execution failed',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'errorTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-3',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
error: new Error('Execution failed'),
|
||||
errorType: ToolErrorType.EXECUTION_FAILED,
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'errorTool',
|
||||
response: {
|
||||
output: 'Error: Execution failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
resultDisplay: 'Execution failed',
|
||||
contentLength: undefined,
|
||||
],
|
||||
resultDisplay: 'Execution failed',
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
const finalResponse: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
@@ -447,7 +461,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-3',
|
||||
});
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool errorTool: Execution failed',
|
||||
);
|
||||
@@ -497,24 +511,26 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-5',
|
||||
},
|
||||
};
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'nonexistentTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-5',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'nonexistentTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-5',
|
||||
},
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
error: new Error('Tool "nonexistentTool" not found in registry.'),
|
||||
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
|
||||
responseParts: [],
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
error: new Error('Tool "nonexistentTool" not found in registry.'),
|
||||
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
|
||||
responseParts: [],
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
});
|
||||
]);
|
||||
const finalResponse: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
@@ -537,7 +553,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-5',
|
||||
});
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
|
||||
);
|
||||
@@ -664,25 +680,27 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
const toolResponse: Part[] = [{ text: 'Tool executed successfully' }];
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-tool-only',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-tool-only',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
// First call returns only tool call, no content
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
@@ -718,9 +736,8 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ name: 'testTool' })],
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
|
||||
@@ -1184,7 +1201,9 @@ describe('runNonInteractive', () => {
|
||||
'./services/FileCommandLoader.js'
|
||||
);
|
||||
const { McpPromptLoader } = await import('./services/McpPromptLoader.js');
|
||||
|
||||
const { BuiltinCommandLoader } = await import(
|
||||
'./services/BuiltinCommandLoader.js'
|
||||
);
|
||||
mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Acknowledged' },
|
||||
@@ -1209,13 +1228,17 @@ describe('runNonInteractive', () => {
|
||||
expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig);
|
||||
expect(McpPromptLoader).toHaveBeenCalledTimes(1);
|
||||
expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig);
|
||||
expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig);
|
||||
|
||||
// Check that instances were passed to CommandService.create
|
||||
expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1);
|
||||
const loadersArg = mockCommandServiceCreate.mock.calls[0][0];
|
||||
expect(loadersArg).toHaveLength(2);
|
||||
expect(loadersArg[0]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]);
|
||||
expect(loadersArg[1]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]);
|
||||
expect(loadersArg).toHaveLength(3);
|
||||
expect(loadersArg[0]).toBe(
|
||||
vi.mocked(BuiltinCommandLoader).mock.instances[0],
|
||||
);
|
||||
expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]);
|
||||
expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]);
|
||||
});
|
||||
|
||||
it('should allow a normally-excluded tool when --allowed-tools is set', async () => {
|
||||
@@ -1241,25 +1264,27 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
const toolResponse: Part[] = [{ text: 'file.txt' }];
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-shell-1',
|
||||
name: 'ShellTool',
|
||||
args: { command: 'ls' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-allowed',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-shell-1',
|
||||
name: 'ShellTool',
|
||||
args: { command: 'ls' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-allowed',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-shell-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-shell-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||
@@ -1281,9 +1306,8 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-allowed',
|
||||
});
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'ShellTool' }),
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ name: 'ShellTool' })],
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('file.txt\n');
|
||||
@@ -1439,20 +1463,22 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [{ text: 'Tool response' }],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [{ text: 'Tool response' }],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Thinking...' },
|
||||
@@ -1629,19 +1655,21 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-tool-error',
|
||||
},
|
||||
};
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
toolCallEvent,
|
||||
@@ -1710,19 +1738,21 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
|
||||
// Mock tool execution returning STOP_EXECUTION
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason from hook'),
|
||||
resultDisplay: undefined,
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason from hook'),
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Executing tool...' },
|
||||
@@ -1743,7 +1773,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-stop',
|
||||
});
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalled();
|
||||
|
||||
// The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input).
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
@@ -1770,19 +1800,21 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Partial content' },
|
||||
@@ -1832,19 +1864,21 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||
|
||||
@@ -2059,5 +2093,63 @@ describe('runNonInteractive', () => {
|
||||
expect.stringContaining('[WARNING] --raw-output is enabled'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-cancel',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the scheduler to return a cancelled status
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'cancelled',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
responseParts: [{ text: 'Operation cancelled' }],
|
||||
resultDisplay: 'Cancelled',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
toolCallEvent,
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Model continues...',
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-cancel',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"type":"tool_result"');
|
||||
expect(output).toContain('"status":"success"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,13 +8,11 @@ import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ResumedSessionData,
|
||||
CompletedToolCall,
|
||||
UserFeedbackPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
executeToolCall,
|
||||
GeminiEventType,
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
@@ -29,6 +27,8 @@ import {
|
||||
createWorkingStdio,
|
||||
recordToolCallInteractions,
|
||||
ToolErrorType,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
@@ -202,6 +202,12 @@ export async function runNonInteractive({
|
||||
});
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const scheduler = new Scheduler({
|
||||
config,
|
||||
messageBus: config.getMessageBus(),
|
||||
getPreferredEditor: () => undefined,
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
});
|
||||
|
||||
// Initialize chat. Resume if resume data is passed.
|
||||
if (resumedSessionData) {
|
||||
@@ -375,25 +381,23 @@ export async function runNonInteractive({
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
textOutput.ensureTrailingNewline();
|
||||
const completedToolCalls = await scheduler.schedule(
|
||||
toolCallRequests,
|
||||
abortController.signal,
|
||||
);
|
||||
const toolResponseParts: Part[] = [];
|
||||
const completedToolCalls: CompletedToolCall[] = [];
|
||||
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const completedToolCall = await executeToolCall(
|
||||
config,
|
||||
requestInfo,
|
||||
abortController.signal,
|
||||
);
|
||||
for (const completedToolCall of completedToolCalls) {
|
||||
const toolResponse = completedToolCall.response;
|
||||
|
||||
completedToolCalls.push(completedToolCall);
|
||||
const requestInfo = completedToolCall.request;
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_id: requestInfo.callId,
|
||||
status: toolResponse.error ? 'error' : 'success',
|
||||
status:
|
||||
completedToolCall.status === 'error' ? 'error' : 'success',
|
||||
output:
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user