Compare commits

...

130 Commits

Author SHA1 Message Date
Shreya Keshive b55bf440f9 init 2026-01-27 17:15:27 -05:00
Shreya Keshive 89337d7d79 init 2026-01-27 13:37:01 -05:00
Shreya Keshive 7904f973a0 fix(admin): fix a few bugs related to admin controls (#17590) 2026-01-27 17:14:11 +00:00
christine betts a63277c1d0 Update sidebar to resolve site build issues (#17674) 2026-01-27 17:09:10 +00:00
Tommaso Sciortino 8b2b71c8ef Change formatting to prevent UI redressing attacks (#17611) 2026-01-27 16:56:01 +00:00
Jacob Richman 6be42be575 Fix cluster of bugs in the settings dialog. (#17628) 2026-01-27 16:55:35 +00:00
christine betts e5145ab60d Fix sidebar issue for extensions link (#17668) 2026-01-27 16:42:10 +00:00
Sehoon Shon 5c16334b8c feat(cli): add oncall command for issue triage (#17661)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-27 16:32:46 +00:00
Jack Wotherspoon 1e628fbd1d chore: remove extra top margin from /hooks and /extensions (#17663) 2026-01-27 16:30:39 +00:00
Mark Cockram db028bc19a refactor: rename formatMemoryUsage to formatBytes (#14997)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-01-27 16:21:53 +00:00
Jacob Richman 362384112e paste transform followup (#17624)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-01-27 14:19:54 +00:00
Akihiro Suda 0dc69bd364 feat(cli): add gemini extensions list --output-format=json (#14479)
Signed-off-by: Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-01-27 13:27:42 +00:00
Godwin Iheuwa 56fff518cc fix(extensions): add .gitignore to extension templates (#17293)
Co-authored-by: RUiNtheExtinct <deepkarma001@gmail.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-01-27 13:24:21 +00:00
Jacob Richman 6b021aa27b Fix iterm alternate buffer mode issue rendering backgrounds (#17634) 2026-01-27 13:15:44 +00:00
Aaron Smith 312a72acb8 feat(ui): pretty JSON rendering tool outputs (#9767)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-01-27 12:55:06 +00:00
Alex Gavrilescu 88d3df912f feat (mcp): Refresh MCP prompts on list changed notification (#14863)
Co-authored-by: christine betts <chrstn@uw.edu>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-01-27 12:50:27 +00:00
Abhi eccc200f4f feat(core): enforce server prefixes for MCP tools in agent definitions (#17574) 2026-01-27 04:53:05 +00:00
Abhi 9fcdc0cdc1 chore(core): delete legacy nonInteractiveToolExecutor (#17573) 2026-01-27 03:56:34 +00:00
Gaurav f4e73191d1 fix: loadcodeassist eligible tiers getting ignored for unlicensed users (regression) (#17581) 2026-01-27 03:45:39 +00:00
Abhi 67b00252d3 refactor(cli): migrate non-interactive flow to event-driven scheduler (#17572) 2026-01-27 03:11:29 +00:00
Jack Wotherspoon a79051d9f8 feat: add double-click to expand/collapse large paste placeholders (#17471) 2026-01-27 02:59:09 +00:00
Christian Gunderman 5cf06503c8 Slash command for helping in debugging (#17609) 2026-01-27 02:47:04 +00:00
Abhi 68649c8dec fix(cli): restore 'Modify with editor' option in external terminals (#17621) 2026-01-27 02:24:25 +00:00
Adib234 ad0bece6d6 feat(plan): update cycling order of approval modes (#17622) 2026-01-27 02:20:51 +00:00
Jacob Richman 8e8e7b33ed Ctrl-O (#17617) 2026-01-27 02:14:03 +00:00
Tommaso Sciortino d75dc88de6 Always map mac keys, even on other platforms (#17618) 2026-01-27 01:06:07 +00:00
Jasmeet Bhatia 57b57cc997 feat(mcp/extensions): Allow users to selectively enable/disable MCP servers included in an extension( Issue #11057 & #17402) (#17434) 2026-01-27 01:02:14 +00:00
Tommaso Sciortino 3909ad67db test(core): fix tests in windows (#17592) 2026-01-27 00:43:44 +00:00
Yuna Seol 7708009103 fix(security): enforce strict policy directory permissions (#17353)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-01-27 00:27:49 +00:00
Gal Zahavi 00f60ef532 feat(cli): add global setting to disable UI spinners (#17234)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-27 00:06:58 +00:00
Jerop Kipruto 49c26b4801 feat(plan): refresh system prompt when approval mode changes (Shift+Tab) (#17585) 2026-01-26 23:45:24 +00:00
Jacob Richman b5fe372b5b feat(ui): add solid background color option for input prompt (#16563)
Co-authored-by: Alexander Farber <farber72@outlook.de>
2026-01-26 23:23:54 +00:00
Jerop Kipruto 7fbf470373 fix(plan): remove subagent invocation from plan mode (#17593) 2026-01-26 22:44:39 +00:00
christine betts 46629726f4 Fix extensions config error (#17580)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-26 22:28:52 +00:00
Abhi 9d34ae52d6 feat(agents): migrate subagents to event-driven scheduler (#17567) 2026-01-26 22:12:55 +00:00
Jerop Kipruto 13bc5f620c feat(plan): add persistent plan file storage (#17563) 2026-01-26 21:57:27 +00:00
Adam Weidman 018dc0d5cf fix(core): stream grep/ripgrep output to prevent OOM (#17146) 2026-01-26 21:52:19 +00:00
Jenna Inouye c2d0783965 Docs: Refactor left nav on the website (#17558) 2026-01-26 21:19:27 +00:00
christine betts b07953fb38 Update extensions docs (#16093) 2026-01-26 20:14:38 +00:00
Christian Gunderman 2271bbb339 feat(agents): implement first-run experience for project-level sub-agents (#17266) 2026-01-26 19:49:32 +00:00
Abhi d745d86af1 feat(scheduler): support multi-scheduler tool aggregation and nested call IDs (#17429) 2026-01-26 18:38:11 +00:00
Jacob Richman 3e1a377d78 Fix bug in detecting already added paths. (#17430) 2026-01-26 18:12:40 +00:00
Sandy Tao 50f89e8a41 feat(core): optimize shell tool llmContent output format (#17538) 2026-01-26 18:12:21 +00:00
Harsha Nadimpalli b8319bee76 feat(cli): add quick clear input shortcuts in vim mode (#17470)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-26 17:36:42 +00:00
Dongjun Shin 4827333c48 fix(core): handle URI-encoded workspace paths in IdeClient (#17476)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-01-26 17:09:43 +00:00
Ali Muthanna 93c62a2bdc Fix/issue 17070 (#17242)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-01-26 16:59:20 +00:00
David Pierce 39e91ad633 Follow up to "enableInteractiveShell for external tooling relying on a2a server" (#17130) 2026-01-26 15:14:48 +00:00
Gaurav 5fe328c56a Improve error messages on failed onboarding (#17357) 2026-01-26 14:31:19 +00:00
Abhi cb772a5b7f docs(hooks): clarify mandatory 'type' field and update hook schema documentation (#17499) 2026-01-25 23:33:12 +00:00
Nils Breunese dcd949bdd0 docs: Add instructions for MacPorts and uninstall instructions for Homebrew (#17412)
Signed-off-by: Nils Breunese <nils@breun.nl>
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-01-25 19:21:16 +00:00
rwa c0b8c4ab9e fix: detect pnpm/pnpx in ~/.local (#15254)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-01-25 19:13:43 +00:00
Maxim Masiutin 4d197c992d Resolves the confusing error message `ripgrep exited with code null that occurs when a search operation is cancelled or aborted (#14267)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-01-25 19:02:37 +00:00
Bryan Morgan fd36d65723 chore(workflow): remove redundant label-enforcer workflow (#17460)
just removing this workflow
2026-01-24 16:54:56 -05:00
Bryan Morgan 05e73c4193 feat(workflow): expand stale-exempt labels to include help wanted and Public Roadmap (#17459) 2026-01-24 16:39:15 -05:00
Bryan Morgan a76c2986c2 feat(workflow): add stale pull request closer with linked-issue enforcement (#17449) 2026-01-24 13:07:56 -05:00
Tommaso Sciortino 80e1fa198f Clean up dead code (#17443) 2026-01-24 15:42:18 +00:00
Vedant Mahajan 84e882770b migrate fireToolNotificationHook to hookSystem (#17398)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-24 13:22:08 +00:00
Gal Zahavi 0242a3dc56 feat: Enforce unified folder trust for /directory add (#17359)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-01-24 05:31:42 +00:00
Sehoon Shon 3c2482a084 fix(core): hide user tier name (#17418) 2026-01-24 03:16:01 +00:00
Abhi 1832f7b90a feat(cli): Moves tool confirmations to a queue UX (#17276)
Co-authored-by: Christian Gunderman <gundermanc@google.com>
2026-01-24 01:32:35 +00:00
Christian Gunderman 77aef861fe fix(agents): default to all tools when tool list is omitted in subagents (#17422) 2026-01-24 01:30:18 +00:00
Sandy Tao 0c134079cc feat: implement AgentConfigDialog for /agents config command (#17370) 2026-01-24 00:10:51 +00:00
Adib234 12a5490bcf Allow prompt queueing during MCP initialization (#17395) 2026-01-23 23:32:06 +00:00
Sandy Tao 93da9817b6 feat(ui): Move keyboard handling into BaseSettingsDialog (#17404) 2026-01-23 23:16:53 +00:00
Adib234 6fae28197e feat(plan): implement persistent approvalMode setting (#17350) 2026-01-23 23:14:11 +00:00
Tommaso Sciortino 00b5b2045f Fix line endings issue with Notice file (#17417) 2026-01-23 15:19:47 -08:00
Dev Randalpura daccf4d6d1 fix(cli): change image paste location to global temp directory (#17396) (#17396)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-23 22:45:24 +00:00
Jack Wotherspoon da1664c7a0 feat: add clearContext to AfterAgent hooks (#16574) 2026-01-23 22:14:30 +00:00
Sehoon Shon 5c649d8db1 feat(ui): display user tier in about command (#17400) 2026-01-23 21:03:53 +00:00
Jack Wotherspoon 2c0cc7b9a5 feat: add AskUserDialog for UI component of AskUser tool (#17344)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-01-23 20:42:48 +00:00
Tommaso Sciortino 25c0802b52 chore: upgrade dep: diff 7.0.0-> 8.0.3 (#17403) 2026-01-23 20:20:21 +00:00
Manoj Naik df379b523b fix(cli): preserve input text when declining tool approval (#15624) (#15659)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-23 19:37:22 +00:00
Sandy Tao 68f5f6d3b0 chore(refactor): extract BaseSettingsDialog component (#17369) 2026-01-23 19:29:29 +00:00
Ishaan Gupta 37c7286295 Add conda installation instructions for Gemini CLI (#16921)
Co-authored-by: Vedant Mahajan <vedant.04.mahajan@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-23 11:32:33 -08:00
Pato Beltran 7b7d2b0297 Update Code Wiki README badge (#15229)
Co-authored-by: Pato Beltran <2132567+PatoBeltran@users.noreply.github.com>
2026-01-23 19:04:20 +00:00
Vijay Vasudevan 3066288c06 fix(core): use RFC 9728 compliant path-based OAuth protected resource discovery (#15756)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-01-23 18:55:23 +00:00
Shreya Keshive 1ec8f40096 fix(oauth): update oauth to use 127.0.0.1 instead of localhost (#17388) 2026-01-23 18:41:37 +00:00
Godwin Iheuwa dabb9ad8f6 fix(core): gracefully handle disk full errors in chat recording (#17305)
Co-authored-by: RUiNtheExtinct <deepkarma001@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-23 18:28:45 +00:00
seeksky b5cac836c5 Fix modifyOtherKeys enablement on unsupported terminals (#16714)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-01-23 17:34:52 +00:00
Ratish P 4fc3ebb930 fix(core): await MCP initialization in non-interactive mode (#17390) 2026-01-23 17:21:47 +00:00
Adib234 488d5fc439 Refactor: Migrate CLI appEvents to Core coreEvents (#15737) 2026-01-23 16:45:46 +00:00
BaeSeokJae 0b7d26c9e3 fix(core): add alternative command names for Antigravity editor detec… (#16829)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-01-23 15:56:39 +00:00
Aaron Smith 140fba7f53 fix: ensure @-command UI message ordering and test (#12038)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-01-23 15:50:45 +00:00
Yuvraj Angad Singh a270f7caa5 fix: exit with non-zero code when esbuild is missing (#16967) 2026-01-23 15:23:15 +00:00
Jerop Kipruto 3c832ddbeb feat(plan): implement simple workflow for planning in main agent (#17326) 2026-01-23 06:53:47 +00:00
Jacob Richman 1f9f3dd1c2 Fix pr-triage.sh script to update pull requests with tags "help wanted" and "maintainer only" (#17324) 2026-01-23 02:57:21 +00:00
Jerop Kipruto 798900a6c8 fix(core): Include MCP server name in OAuth message (#17351) 2026-01-23 02:48:15 +00:00
Christian Gunderman 2c6781d134 Refactor subagent delegation to be one tool per agent (#17346) 2026-01-23 02:18:31 +00:00
Gaurav 07bd89399d fix: remove obsolete CloudCode PerDay quota and 120s terminal threshold (#17236) 2026-01-23 01:33:32 +00:00
Allen Hutchison 1ec172e34b fix(cli): include source in policy rule display (#17358) 2026-01-23 00:36:44 +00:00
joshualitt 678c58634b feat(core): Refresh agents after loading an extension. (#17355) 2026-01-23 00:22:22 +00:00
Jacob Richman a1f5d39029 Fix issue #17080 (#17100)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-01-23 00:02:14 +00:00
Ishaan Gupta beacc4f6fd fix(cli)!: Default to interactive mode for positional arguments (#16329)
Co-authored-by: Allen Hutchison <adh@google.com>
2026-01-22 23:38:53 +00:00
Jasmeet Bhatia a060e6149a feat(mcp): add enable/disable commands for MCP servers (#11057) (#16299)
Co-authored-by: Allen Hutchison <adh@google.com>
2026-01-22 23:38:06 +00:00
Sandy Tao 35feea8868 feat(cli): add /agents config command and improve agent discovery (#17342) 2026-01-22 23:22:56 +00:00
lkk214 11e48e2dd1 fix(ui): stabilize rendering during terminal resize in alternate buffer (#15783)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-01-22 23:21:55 +00:00
Adib234 5f1c6447a9 feat(plan): update UI Theme for Plan Mode (#17243) 2026-01-22 22:22:21 +00:00
matt korwel 57601adc90 feat(routing): A/B Test Numerical Complexity Scoring for Gemini 3 (#16041)
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2026-01-22 22:12:07 +00:00
Jerop Kipruto 50985d38c4 feat(plan): add 'communicate' tool kind (#17341) 2026-01-22 21:38:15 +00:00
joshualitt 62dd9b5b3c feat(core): Remove legacy settings. (#17244) 2026-01-22 20:59:47 +00:00
Jacob Richman 2ac7900d95 Fix so rewind starts at the bottom and loadHistory refreshes static content. (#17335) 2026-01-22 20:54:23 +00:00
Adib234 016a94ffaf Disable tips after 10 runs (#17101) 2026-01-22 20:46:18 +00:00
Spencer 5d68d8cda5 fix(core): Resolve AbortSignal MaxListenersExceededWarning (#5950) (#16735) 2026-01-22 20:16:00 +00:00
g-samroberts e2ddaedab4 New skill: docs-writer (#17268) 2026-01-22 20:14:52 +00:00
Tommaso Sciortino addb57c31f Emit correct newline type return (#17331) 2026-01-22 19:54:34 +00:00
kapsner 233fe90f17 bug: fix ide-client connection to ide-companion when inside docker via ssh/devcontainer (#15049)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-22 19:19:04 +00:00
Sandy Tao 902e5d6dae feat(cli): Add state management and plumbing for agent configuration dialog (#17259) 2026-01-22 18:30:44 +00:00
Christian Gunderman ba8c64459b ci: allow failure in evals-nightly run step (#17319) 2026-01-22 18:23:15 +00:00
Jacob Richman 1320ebceb9 Remove redundant calls setting linuxClipboardTool. getUserLinuxClipboardTool() now handles the caching internally (#17320) 2026-01-22 18:11:56 +00:00
Saksham Goyal 6d7423263b fix: show whitespace changes in edit tool diffs (#17213)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-01-22 18:02:37 +00:00
Mag1ck fe06f9a1ea fix cli settings: resolve layout jitter in settings bar (#16256) 2026-01-22 17:38:38 +00:00
Jack Wotherspoon e65208b244 feat: add AskUser tool schema (#16988) 2026-01-22 17:12:13 +00:00
Dev Randalpura 7962801a14 Added image pasting capabilities for Wayland and X11 on Linux (#17144) 2026-01-22 17:02:56 +00:00
Shreya Keshive 87a0db20d2 fix(auth): don't crash when initial auth fails (#17308) 2026-01-22 16:44:55 +00:00
Emily Hedlund d956c5b221 Sanitize command names and descriptions (#17228) 2026-01-22 16:41:51 +00:00
Lucas Schiffer 048c30513e docs: update typo in mcp-server.md file (#17099)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-01-22 15:48:26 +00:00
Adib234 3b9f580fa4 feat: implement /rewind command (#15720) 2026-01-22 15:26:52 +00:00
Chuck ff9c77925e Feature/jetbrains ide detection (#16243)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-01-22 15:21:05 +00:00
Jacob Richman 56f9ab025a refactor: improve large text paste placeholder (#17269)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-01-22 15:05:38 +00:00
Abhi 0a173cac46 docs(hooks): comprehensive update of hook documentation and specs (#16816) 2026-01-22 03:13:15 +00:00
Christian Gunderman 982a093791 Enable the ability to queue specific nightly eval tests (#17262) 2026-01-22 03:07:33 +00:00
Yuna Seol 5debf86854 security(hooks): Wrap hook-injected context in distinct XML tags (#17237)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-01-22 02:11:45 +00:00
Jack Wotherspoon 75e4f492ab feat: replace large text pastes with [Pasted Text: X lines] placeholder (#16422) 2026-01-22 02:09:24 +00:00
joshualitt 27d21f9921 feat(core): Have subagents use a JSON schema type for input. (#17152) 2026-01-22 00:56:01 +00:00
Sandy Tao b24544c80e feat(core): Enable AgentRegistry to track all discovered subagents (#17253) 2026-01-22 00:19:04 +00:00
Abhi 61040d0eb8 feat(hooks): enable hooks system by default (#17247) 2026-01-21 22:40:31 +00:00
Abhi 3cca8ec0f0 feat(config): default enableEventDrivenScheduler to true (#17211) 2026-01-21 22:40:19 +00:00
Abhi 2219bac16d fix(scheduler): prevent stale tool re-publication and fix stuck UI state (#17227) 2026-01-21 22:39:20 +00:00
N. Taylor Mullen 1c9a57c3c2 feat(cli): consolidate shell inactivity and redirection monitoring (#17086) 2026-01-21 22:31:24 +00:00
Sandy Tao a1233e7e5c fix(core): simplify replace tool error message (#17246) 2026-01-21 22:25:36 +00:00
457 changed files with 29167 additions and 13006 deletions
+60
View File
@@ -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}}
"""
+63
View File
@@ -0,0 +1,63 @@
---
name: docs-writer
description:
Use this skill when asked to write documentation (`/docs` directory)
for Gemini CLI.
---
# `docs-writer` skill instructions
As an expert technical writer for the Gemini CLI project, your goal is to
produce documentation that is accurate, clear, and consistent with the project's
standards. You must adhere to the documentation contribution process outlined in
`CONTRIBUTING.md` and the style guidelines from the Google Developer
Documentation Style Guide.
## 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 to be documented.
2. **Ask questions:** If the request is ambiguous or lacks detail, ask
clarifying questions. Don't invent or assume. It's better to ask than to
write incorrect documentation.
3. **Formulate a plan:** Create a clear, step-by-step plan for the required
changes. If requested or necessary, store this plan in a temporary file or
a file identified by the user.
## Step 2: Investigate and gather information
1. **Read the code:** Thoroughly examine the relevant codebase, primarily within
the `packages/` directory, to ensure your writing is backed by the
implementation.
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 to edit it.
3. **Check for connections:** Consider related documentation. If you add a new
page, check if `docs/sidebar.json` needs to be updated. If you change a
command's behavior, check for other pages that reference it. Make sure links
in these pages are up to date.
## Step 3: Draft the documentation
1. **Follow the style guide:**
- Text must be wrapped at 80 characters. Exceptions are long links or
tables, unless otherwise stated by the user.
- Use sentence case for headings, titles, and bolded text.
- Address the reader as "you".
- Use contractions to keep the tone more casual.
- Use simple, direct, and active language and the present tense.
- Keep paragraphs short and focused.
- Always refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
2. **Use `replace` and `write_file`:** Use the file system tools to apply your
planned changes precisely. For small edits, `replace` is preferred. For new
files or large rewrites, `write_file` is more appropriate.
## Step 4: Verify and finalize
1. **Review your work:** After making changes, re-read the files to ensure the
documentation is well-formatted, content is correct and based on existing
code, and that all new links are valid.
2. **Offer to run npm format:** Once all changes are complete and the user
confirms they have no more requests, offer to run the project's formatting
script to ensure consistency. Propose the following command:
`npm run format`
+12 -12
View File
@@ -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
}
+19 -1
View File
@@ -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'
});
}
}
}
}
-119
View File
@@ -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
});
}
}
+2 -2
View File
@@ -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'
+1
View File
@@ -55,6 +55,7 @@ gha-creds-*.json
# Log files
patch_output.log
gemini-debug.log
.genkit
.gemini-clipboard/
+11 -5
View File
@@ -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.
+19 -2
View File
@@ -4,7 +4,7 @@
[![Gemini CLI E2E (Chained)](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml/badge.svg)](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml)
[![Version](https://img.shields.io/npm/v/@google/gemini-cli)](https://www.npmjs.com/package/@google/gemini-cli)
[![License](https://img.shields.io/github/license/google-gemini/gemini-cli)](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE)
[![View Code Wiki](https://www.gstatic.com/_/boq-sdlc-agents-ui/_/r/YUi5dj2UWvE.svg)](https://codewiki.google/github.com/google-gemini/gemini-cli)
[![View Code Wiki](https://assets.codewiki.google/readme-badge/static.svg)](https://codewiki.google/github.com/google-gemini/gemini-cli?utm_source=badge&utm_medium=github&utm_campaign=github.com/google-gemini/gemini-cli)
![Gemini CLI Screenshot](./docs/assets/gemini-screenshot.png)
@@ -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.
@@ -372,5 +389,5 @@ See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
---
<p align="center">
Built with ❤️ by Google and the open source community
Built with ❤️ by Google, shreya, and the open source community
</p>
+18 -14
View File
@@ -62,6 +62,7 @@ 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
@@ -93,20 +94,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 +124,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.
+18 -19
View File
@@ -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` |
@@ -90,16 +91,17 @@ they appear in the UI.
### 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,14 +115,11 @@ 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 |
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
| 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` |
### HooksConfig
+18
View File
@@ -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
```
+32
View File
@@ -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:
+139
View File
@@ -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.
+25 -358
View File
@@ -1,377 +1,44 @@
# 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
Gemini CLI extensions package prompts, MCP servers, custom commands, hooks, 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're designed to be easily installable and shareable.
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.
+312
View File
@@ -0,0 +1,312 @@
# 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.
### 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 `gemini-extension.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). |
@@ -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}"
}
}
@@ -265,7 +266,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
+17 -38
View File
@@ -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.
@@ -650,6 +654,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 +848,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):
@@ -855,43 +866,11 @@ their corresponding top-level category object in your `settings.json` file.
- **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`
@@ -909,7 +888,7 @@ 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`
- **`hooksConfig.disabled`** (array):
- **Description:** List of hook names (commands) that should be disabled.
+252 -431
View File
@@ -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.
+123 -668
View File
@@ -4,109 +4,115 @@ 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.
## Availability
See [writing hooks guide](writing-hooks.md) for a tutorial on creating your
first hook and a comprehensive example.
> **Experimental Feature**: Hooks are currently enabled by default only in the
> **Preview** and **Nightly** release channels.
See [hooks reference](reference.md) for the technical specification of the I/O
schemas.
If you are on the Stable channel, you must explicitly enable the hooks system in
your `settings.json`:
See [best practices](best-practices.md) for guidelines on security, performance,
and debugging.
```json
{
"hooksConfig": {
"enabled": true
}
}
```
- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating
your first hook with comprehensive examples.
- **[Hooks reference](/docs/hooks/reference)**: The definitive technical
specification of I/O schemas and exit codes.
- **[Best practices](/docs/hooks/best-practices)**: 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:
- **Exfiltrate data**: Read sensitive files (`.env`, ssh keys) and send them to
remote servers.
- **Modify system**: Delete files, install malware, or change system settings.
- **Consume resources**: Run infinite loops or crash your system.
**Project-level hooks** (in `.gemini/settings.json`) and **Extension hooks** are
particularly risky when opening third-party projects or extensions from
untrusted authors. Gemini CLI will **warn you** the first time it detects a new
project hook (identified by its name and command), but it is **your
responsibility** to review these hooks (and any installed extensions) before
trusting them.
> **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.
- **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.
## 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 +120,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 +134,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
View File
@@ -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 }
}
```
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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
+80 -245
View File
@@ -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,76 @@
"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 (experimental)", "slug": "docs/cli/skills" },
{ "label": "Hooks (experimental)", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" }
]
},
{
"label": "Tutorials",
"items": [
{
"label": "Introduction",
"slug": "docs/ide-integration"
"label": "Get started with extensions",
"slug": "docs/extensions/writing-extensions"
},
{
"label": "IDE companion spec",
"slug": "docs/ide-integration/ide-companion-spec"
}
{ "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" }
]
}
]
+24 -1
View File
@@ -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
-3
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+2 -9
View File
@@ -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
View File
@@ -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']);
},
});
});
+110
View File
@@ -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', () => {
+274 -1029
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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"
},
@@ -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(() => ({
+1
View File
@@ -97,6 +97,7 @@ export async function loadConfig(
previewFeatures: settings.general?.previewFeatures,
interactive: true,
enableInteractiveShell: true,
ptyInfo: 'auto',
};
const fileService = new FileDiscoveryService(workspaceDir);
@@ -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 />);
+2 -3
View File
@@ -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",
@@ -174,6 +174,7 @@ async function configureExtensionSettings(
extensionConfig,
extensionId,
scope,
process.cwd(),
);
let workspaceSettings: Record<string, string> = {};
@@ -182,6 +183,7 @@ async function configureExtensionSettings(
extensionConfig,
extensionId,
ExtensionSettingScope.WORKSPACE,
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}".`,
@@ -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/
@@ -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();
});
});
+30 -12
View File
@@ -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)}`);
}
+3 -1
View File
@@ -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,
+3
View File
@@ -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();
},
};
+1 -1
View File
@@ -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();
+109 -15
View File
@@ -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) => {
@@ -371,6 +373,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([
@@ -1953,7 +1970,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 +1979,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 +1998,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 +2013,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 +2028,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 +2053,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 +2091,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 +2235,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 +2325,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 +2802,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(),
+39 -24
View File
@@ -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 || [],
@@ -872,6 +872,7 @@ describe('extension tests', () => {
);
const settings = loadSettings(tempWorkspaceDir).merged;
settings.hooksConfig.enabled = false;
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
+8 -1
View File
@@ -49,6 +49,7 @@ 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',
@@ -188,6 +189,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' }],
@@ -251,7 +253,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 +322,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
Command.REWIND,
],
},
{
@@ -413,6 +419,7 @@ 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.',
+17
View File
@@ -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: {
+113 -7
View File
@@ -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', () => {
+114 -10
View File
@@ -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;
}
@@ -387,7 +387,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(
+38 -92
View File
@@ -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',
@@ -1024,6 +1032,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 +1455,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,
},
@@ -1461,66 +1487,6 @@ const SETTINGS_SCHEMA = {
description: 'Enable Agent Skills (experimental).',
showInDialog: true,
},
codebaseInvestigatorSettings: {
type: 'object',
label: 'Codebase Investigator Settings',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Configuration for Codebase Investigator.',
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',
label: 'Use OSC 52 Paste',
@@ -1531,26 +1497,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',
@@ -1646,7 +1592,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
default: false,
default: true,
description:
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
showInDialog: false,
@@ -155,8 +155,12 @@ describe('Settings Repro', () => {
experimental: {
useModelRouter: false,
enableSubagents: false,
codebaseInvestigatorSettings: {
enabled: true,
},
agents: {
overrides: {
codebase_investigator: {
enabled: true,
},
},
},
ui: {
+35 -15
View File
@@ -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,
);
});
});
+6
View File
@@ -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)}`;
}
+28 -2
View File
@@ -13,6 +13,8 @@ import {
StartSessionEvent,
logCliConfiguration,
startupProfiler,
type ConnectionConfig,
type IdeInfo,
} from '@google/gemini-cli-core';
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
@@ -23,6 +25,9 @@ export interface InitializationResult {
themeError: string | null;
shouldOpenAuthDialog: boolean;
geminiMdFileCount: number;
availableIdeConnections?: Array<
ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }
>;
}
/**
@@ -52,10 +57,30 @@ export async function initializeApp(
new StartSessionEvent(config, config.getToolRegistry()),
);
let availableIdeConnections:
| Array<ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }>
| undefined;
if (config.getIdeMode()) {
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
// Try to auto-connect if possible (legacy behavior or single match)
// We attempt to connect. If it requires selection, we get a list back?
// No, I need to implement the logic here.
const connections = await ideClient.discoverAvailableConnections();
// Heuristic: If we have a PID match, prioritize it.
// Ideally IdeClient.getInstance() already did some detection but didn't connect.
// Actually IdeClient.connect() (without args) tries to find "the one" config.
// If I want to support multiple, I should check here.
if (connections.length > 1) {
// Multiple connections found, let the UI handle selection
availableIdeConnections = connections;
} else {
// 0 or 1 connection, or let connect() handle the "best guess" fallback
await ideClient.connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
}
}
return {
@@ -63,5 +88,6 @@ export async function initializeApp(
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
availableIdeConnections,
};
}
+2
View File
@@ -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({
+29 -6
View File
@@ -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,
+279 -187
View File
@@ -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"');
});
});
});
+16 -12
View File
@@ -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
@@ -13,6 +13,7 @@ import {
type Config,
} from '@google/gemini-cli-core';
import { CommandService } from './services/CommandService.js';
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js';
import { McpPromptLoader } from './services/McpPromptLoader.js';
import type { CommandContext } from './ui/commands/types.js';
@@ -40,7 +41,11 @@ export const handleSlashCommand = async (
}
const commandService = await CommandService.create(
[new McpPromptLoader(config), new FileCommandLoader(config)],
[
new BuiltinCommandLoader(config),
new McpPromptLoader(config),
new FileCommandLoader(config),
],
abortController.signal,
);
const commands = commandService.getCommands();
@@ -27,12 +27,14 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { oncallCommand } from '../ui/commands/oncallCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { policiesCommand } from '../ui/commands/policiesCommand.js';
@@ -106,8 +108,10 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
await ideCommand(),
initCommand,
...(isNightlyBuild ? [oncallCommand] : []),
...(this.config?.getMcpEnabled() === false
? [
{
@@ -1337,4 +1337,69 @@ describe('FileCommandLoader', () => {
consoleErrorSpy.mockRestore();
});
});
describe('Sanitization', () => {
it('sanitizes command names from filenames containing control characters', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test\twith\nnewlines.toml': 'prompt = "Test prompt"',
},
});
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
// Non-alphanumeric characters (except - and .) become underscores
expect(commands[0].name).toBe('test_with_newlines');
});
it('truncates excessively long filenames', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
const longName = 'a'.repeat(60) + '.toml';
mock({
[userCommandsDir]: {
[longName]: 'prompt = "Test prompt"',
},
});
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
expect(commands[0].name.length).toBe(50);
expect(commands[0].name).toBe('a'.repeat(47) + '...');
});
it('sanitizes descriptions containing newlines and ANSI codes', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.toml':
'prompt = "Test"\ndescription = "Line 1\\nLine 2\\tTabbed\\r\\n\\u001B[31mRed text\\u001B[0m"',
},
});
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
// Newlines and tabs become spaces, ANSI is stripped
expect(commands[0].description).toBe('Line 1 Line 2 Tabbed Red text');
});
it('truncates long descriptions', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
const longDesc = 'd'.repeat(150);
mock({
[userCommandsDir]: {
'test.toml': `prompt = "Test"\ndescription = "${longDesc}"`,
},
});
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
expect(commands[0].description.length).toBe(100);
expect(commands[0].description).toBe('d'.repeat(97) + '...');
});
});
});
+15 -4
View File
@@ -33,6 +33,7 @@ import {
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
import { sanitizeForDisplay } from '../ui/utils/textUtils.js';
interface CommandDirectory {
path: string;
@@ -230,15 +231,25 @@ export class FileCommandLoader implements ICommandLoader {
);
const baseCommandName = relativePath
.split(path.sep)
// Sanitize each path segment to prevent ambiguity. Since ':' is our
// namespace separator, we replace any literal colons in filenames
// with underscores to avoid naming conflicts.
.map((segment) => segment.replaceAll(':', '_'))
// Sanitize each path segment to prevent ambiguity, replacing non-allowlisted characters with underscores.
// Since ':' is our namespace separator, this ensures that colons do not cause naming conflicts.
.map((segment) => {
let sanitized = segment.replace(/[^a-zA-Z0-9_\-.]/g, '_');
// Truncate excessively long segments to prevent UI overflow
if (sanitized.length > 50) {
sanitized = sanitized.substring(0, 47) + '...';
}
return sanitized;
})
.join(':');
// Add extension name tag for extension commands
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
let description = validDef.description || defaultDescription;
description = sanitizeForDisplay(description, 100);
if (extensionName) {
description = `[${extensionName}] ${description}`;
}
@@ -61,6 +61,8 @@ export const createMockCommandContext = (
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
openAgentConfigDialog: vi.fn(),
closeAgentConfigDialog: vi.fn(),
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
/**
* A fake implementation of PersistentState for testing.
* It keeps state in memory and provides spies for get and set.
*/
export class FakePersistentState {
private data: Record<string, unknown> = {};
get = vi.fn().mockImplementation((key: string) => this.data[key]);
set = vi.fn().mockImplementation((key: string, value: unknown) => {
this.data[key] = value;
});
/**
* Helper to reset the fake state between tests.
*/
reset() {
this.data = {};
this.get.mockClear();
this.set.mockClear();
}
/**
* Helper to clear mock call history without wiping data.
*/
mockClear() {
this.get.mockClear();
this.set.mockClear();
}
/**
* Helper to set initial data for the fake.
*/
setData(data: Record<string, unknown>) {
this.data = { ...data };
}
}
+95 -47
View File
@@ -9,13 +9,13 @@ import { Box } from 'ink';
import type React from 'react';
import { vi } from 'vitest';
import { act, useState } from 'react';
import os from 'node:os';
import { LoadedSettings, type Settings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js';
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
import { MouseProvider } from '../ui/contexts/MouseContext.js';
import { ScrollProvider } from '../ui/contexts/ScrollProvider.js';
@@ -27,7 +27,21 @@ import {
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
import { type Config } from '@google/gemini-cli-core';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
export const persistentStateMock = new FakePersistentState();
vi.mock('../utils/persistentState.js', () => ({
persistentState: persistentStateMock,
}));
vi.mock('../ui/utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
getColorDepth: vi.fn(() => 24),
isITerm2: vi.fn(() => false),
}));
// Wrapper around ink-testing-library's render that ensures act() is called
export const render = (
@@ -84,21 +98,27 @@ export const simulateClick = async (
});
};
const mockConfig = {
getModel: () => 'gemini-pro',
getTargetDir: () =>
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
getDebugMode: () => false,
isTrustedFolder: () => true,
getIdeMode: () => false,
getEnableInteractiveShell: () => true,
getPreviewFeatures: () => false,
let mockConfigInternal: Config | undefined;
const getMockConfigInternal = (): Config => {
if (!mockConfigInternal) {
mockConfigInternal = makeFakeConfig({
targetDir: os.tmpdir(),
enableEventDrivenScheduler: true,
});
}
return mockConfigInternal;
};
const configProxy = new Proxy(mockConfig, {
get(target, prop) {
if (prop in target) {
return target[prop as keyof typeof target];
const configProxy = new Proxy({} as Config, {
get(_target, prop) {
if (prop === 'getTargetDir') {
return () =>
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
}
const internal = getMockConfigInternal();
if (prop in internal) {
return internal[prop as keyof typeof internal];
}
throw new Error(`mockConfig does not have property ${String(prop)}`);
},
@@ -132,13 +152,17 @@ export const createMockSettings = (
const baseMockUiState = {
renderMarkdown: true,
streamingState: StreamingState.Idle,
mainAreaWidth: 100,
terminalWidth: 120,
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: undefined,
};
export const mockAppState: AppState = {
version: '1.2.3',
startupWarnings: [],
};
const mockUIActions: UIActions = {
handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(),
@@ -151,6 +175,8 @@ const mockUIActions: UIActions = {
exitPrivacyNotice: vi.fn(),
closeSettingsDialog: vi.fn(),
closeModelDialog: vi.fn(),
openAgentConfigDialog: vi.fn(),
closeAgentConfigDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openSessionBrowser: vi.fn(),
closeSessionBrowser: vi.fn(),
@@ -176,6 +202,7 @@ const mockUIActions: UIActions = {
setEmbeddedShellFocused: vi.fn(),
setAuthContext: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
export const renderWithProviders = (
@@ -189,6 +216,8 @@ export const renderWithProviders = (
config = configProxy as unknown as Config,
useAlternateBuffer = true,
uiActions,
persistentState,
appState = mockAppState,
}: {
shellFocus?: boolean;
settings?: LoadedSettings;
@@ -198,6 +227,11 @@ export const renderWithProviders = (
config?: Config;
useAlternateBuffer?: boolean;
uiActions?: Partial<UIActions>;
persistentState?: {
get?: typeof persistentStateMock.get;
set?: typeof persistentStateMock.set;
};
appState?: AppState;
} = {},
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
const baseState: UIState = new Proxy(
@@ -218,6 +252,15 @@ export const renderWithProviders = (
},
) as UIState;
if (persistentState?.get) {
persistentStateMock.get.mockImplementation(persistentState.get);
}
if (persistentState?.set) {
persistentStateMock.set.mockImplementation(persistentState.set);
}
persistentStateMock.mockClear();
const terminalWidth = width ?? baseState.terminalWidth;
let finalSettings = settings;
if (useAlternateBuffer !== undefined) {
@@ -230,7 +273,7 @@ export const renderWithProviders = (
});
}
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, finalSettings);
const mainAreaWidth = terminalWidth;
const finalUiState = {
...baseState,
@@ -245,36 +288,41 @@ export const renderWithProviders = (
.flatMap((item) => item.tools);
const renderResult = render(
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
<ShellFocusContext.Provider value={shellFocus}>
<StreamingContext.Provider value={finalUiState.streamingState}>
<UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<KeypressProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</ToolActionsProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
<ShellFocusContext.Provider value={shellFocus}>
<StreamingContext.Provider value={finalUiState.streamingState}>
<UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<KeypressProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</ToolActionsProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>,
terminalWidth,
);
+105 -72
View File
@@ -4,17 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, type Mock } from 'vitest';
import { render } from '../test-utils/render.js';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import type React from 'react';
import { renderWithProviders } from '../test-utils/render.js';
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
import { type Config } from '@google/gemini-cli-core';
import { App } from './App.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import { StreamingState } from './types.js';
import { ConfigContext } from './contexts/ConfigContext.js';
import { AppContext, type AppState } from './contexts/AppContext.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import { LoadedSettings, type SettingsFile } from '../config/settings.js';
import { type UIState } from './contexts/UIStateContext.js';
import { StreamingState, ToolCallStatus } from './types.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
vi.mock('ink', async (importOriginal) => {
const original = await importOriginal<typeof import('ink')>();
@@ -53,12 +51,20 @@ vi.mock('./components/Footer.js', () => ({
}));
describe('App', () => {
beforeEach(() => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
});
const mockUIState: Partial<UIState> = {
streamingState: StreamingState.Idle,
quittingMessages: null,
dialogsVisible: false,
mainControlsRef: { current: null },
rootUiRef: { current: null },
mainControlsRef: {
current: null,
} as unknown as React.MutableRefObject<DOMElement | null>,
rootUiRef: {
current: null,
} as unknown as React.MutableRefObject<DOMElement | null>,
historyManager: {
addItem: vi.fn(),
history: [],
@@ -68,49 +74,18 @@ describe('App', () => {
},
history: [],
pendingHistoryItems: [],
pendingGeminiHistoryItems: [],
bannerData: {
defaultText: 'Mock Banner Text',
warningText: '',
},
};
const mockConfig = makeFakeConfig();
const mockSettingsFile: SettingsFile = {
settings: {},
originalSettings: {},
path: '/mock/path',
};
const mockLoadedSettings = new LoadedSettings(
mockSettingsFile,
mockSettingsFile,
mockSettingsFile,
mockSettingsFile,
true,
[],
);
const mockAppState: AppState = {
version: '1.0.0',
startupWarnings: [],
};
const renderWithProviders = (ui: React.ReactElement, state: UIState) =>
render(
<AppContext.Provider value={mockAppState}>
<ConfigContext.Provider value={mockConfig}>
<SettingsContext.Provider value={mockLoadedSettings}>
<UIStateContext.Provider value={state}>
{ui}
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>,
);
it('should render main content and composer when not quitting', () => {
const { lastFrame } = renderWithProviders(<App />, mockUIState as UIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
useAlternateBuffer: false,
});
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('Notifications');
@@ -123,7 +98,10 @@ describe('App', () => {
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: quittingUIState,
useAlternateBuffer: false,
});
expect(lastFrame()).toContain('Quitting...');
});
@@ -136,15 +114,13 @@ describe('App', () => {
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
} as UIState;
mockLoadedSettings.merged.ui.useAlternateBuffer = true;
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: quittingUIState,
useAlternateBuffer: true,
});
expect(lastFrame()).toContain('HistoryItemDisplay');
expect(lastFrame()).toContain('Quitting...');
// Reset settings
mockLoadedSettings.merged.ui.useAlternateBuffer = false;
});
it('should render dialog manager when dialogs are visible', () => {
@@ -153,7 +129,9 @@ describe('App', () => {
dialogsVisible: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, dialogUIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: dialogUIState,
});
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('Notifications');
@@ -172,7 +150,9 @@ describe('App', () => {
[stateKey]: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, uiState);
const { lastFrame } = renderWithProviders(<App />, {
uiState,
});
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
},
@@ -181,37 +161,88 @@ describe('App', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<App />, mockUIState as UIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
expect(lastFrame()).toContain(
'Notifications\nFooter\nMainContent\nComposer',
);
expect(lastFrame()).toContain(`Notifications
Footer
MainContent
Composer`);
});
it('should render DefaultAppLayout when screen reader is not enabled', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame } = renderWithProviders(<App />, mockUIState as UIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
expect(lastFrame()).toContain('MainContent\nNotifications\nComposer');
expect(lastFrame()).toContain(`MainContent
Notifications
Composer`);
});
it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const toolCalls = [
{
callId: 'call-1',
name: 'ls',
description: 'list directory',
status: ToolCallStatus.Confirming,
resultDisplay: '',
confirmationDetails: {
type: 'exec' as const,
title: 'Confirm execution',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
];
const stateWithConfirmingTool = {
...mockUIState,
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
} as UIState;
const configWithExperiment = {
...makeFakeConfig(),
isEventDrivenSchedulerEnabled: () => true,
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(<App />, {
uiState: stateWithConfirmingTool,
config: configWithExperiment,
});
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
expect(lastFrame()).toContain('1 of 1');
expect(lastFrame()).toContain('Composer');
expect(lastFrame()).toMatchSnapshot();
});
describe('Snapshots', () => {
it('renders default layout correctly', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame } = renderWithProviders(
<App />,
mockUIState as UIState,
);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders screen reader layout correctly', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame } = renderWithProviders(
<App />,
mockUIState as UIState,
);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
expect(lastFrame()).toMatchSnapshot();
});
@@ -220,7 +251,9 @@ describe('App', () => {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, dialogUIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: dialogUIState,
});
expect(lastFrame()).toMatchSnapshot();
});
});
+210 -3
View File
@@ -20,6 +20,7 @@ import { cleanup } from 'ink-testing-library';
import { act, useContext, type ReactElement } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
import {
type Config,
makeFakeConfig,
@@ -27,6 +28,7 @@ import {
type UserFeedbackPayload,
type ResumedSessionData,
AuthType,
type AgentDefinition,
} from '@google/gemini-cli-core';
// Mock coreEvents
@@ -1274,8 +1276,12 @@ describe('AppContainer State Management', () => {
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
lastOutputTime: 0,
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime + 100, // Trigger aggressive delay
retryStatus: null,
});
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
@@ -1309,6 +1315,136 @@ describe('AppContainer State Management', () => {
unmount();
});
it('should show Working… in title for redirected commands after 2 mins', async () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// Mock an active shell pty with redirection active
mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [
{
request: {
name: 'run_shell_command',
args: { command: 'ls > out' },
},
status: 'executing',
} as unknown as TrackedToolCall,
],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime,
retryStatus: null,
});
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
const { unmount } = renderAppContainer({
settings: mockSettingsWithTitleEnabled,
});
// Fast-forward time by 65 seconds - should still NOT be Action Required
await act(async () => {
await vi.advanceTimersByTimeAsync(65000);
});
const titleWritesMid = mocks.mockStdout.write.mock.calls.filter(
(call) => call[0].includes('\x1b]0;'),
);
expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain(
'✋ Action Required',
);
// Fast-forward to 2 minutes (120000ms)
await act(async () => {
await vi.advanceTimersByTimeAsync(60000);
});
const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter(
(call) => call[0].includes('\x1b]0;'),
);
expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain(
'⏲ Working…',
);
unmount();
});
it('should show Working… in title for silent non-redirected commands after 1 min', async () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
},
} as unknown as LoadedSettings;
// Mock an active shell pty with NO output since operation started (silent)
mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
retryStatus: null,
});
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
const { unmount } = renderAppContainer({
settings: mockSettingsWithTitleEnabled,
});
// Fast-forward time by 65 seconds
await act(async () => {
await vi.advanceTimersByTimeAsync(65000);
});
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
call[0].includes('\x1b]0;'),
);
const lastTitle = titleWrites[titleWrites.length - 1][0];
// Should show Working… (⏲) instead of Action Required (✋)
expect(lastTitle).toContain('⏲ Working…');
unmount();
});
it('should NOT show Action Required in title if shell is streaming output', async () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
@@ -1327,7 +1463,7 @@ describe('AppContainer State Management', () => {
} as unknown as LoadedSettings;
// Mock an active shell pty but not focused
let lastOutputTime = 1000;
let lastOutputTime = startTime + 1000;
mockedUseGeminiStream.mockImplementation(() => ({
streamingState: 'responding',
submitQuery: vi.fn(),
@@ -1353,7 +1489,7 @@ describe('AppContainer State Management', () => {
});
// Update lastOutputTime to simulate new output
lastOutputTime = 21000;
lastOutputTime = startTime + 21000;
mockedUseGeminiStream.mockImplementation(() => ({
streamingState: 'responding',
submitQuery: vi.fn(),
@@ -2012,6 +2148,77 @@ describe('AppContainer State Management', () => {
});
});
describe('Agent Configuration Dialog Integration', () => {
it('should initialize with dialog closed and no agent selected', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
expect(capturedUIState.isAgentConfigDialogOpen).toBe(false);
expect(capturedUIState.selectedAgentName).toBeUndefined();
expect(capturedUIState.selectedAgentDisplayName).toBeUndefined();
expect(capturedUIState.selectedAgentDefinition).toBeUndefined();
unmount!();
});
it('should update state when openAgentConfigDialog is called', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const agentDefinition = { name: 'test-agent' };
act(() => {
capturedUIActions.openAgentConfigDialog(
'test-agent',
'Test Agent',
agentDefinition as unknown as AgentDefinition,
);
});
expect(capturedUIState.isAgentConfigDialogOpen).toBe(true);
expect(capturedUIState.selectedAgentName).toBe('test-agent');
expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent');
expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition);
unmount!();
});
it('should clear state when closeAgentConfigDialog is called', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => expect(capturedUIState).toBeTruthy());
const agentDefinition = { name: 'test-agent' };
act(() => {
capturedUIActions.openAgentConfigDialog(
'test-agent',
'Test Agent',
agentDefinition as unknown as AgentDefinition,
);
});
expect(capturedUIState.isAgentConfigDialogOpen).toBe(true);
act(() => {
capturedUIActions.closeAgentConfigDialog();
});
expect(capturedUIState.isAgentConfigDialogOpen).toBe(false);
expect(capturedUIState.selectedAgentName).toBeUndefined();
expect(capturedUIState.selectedAgentDisplayName).toBeUndefined();
expect(capturedUIState.selectedAgentDefinition).toBeUndefined();
unmount!();
});
});
describe('CoreEvents Integration', () => {
it('subscribes to UserFeedback and drains backlog on mount', async () => {
let unmount: () => void;
+243 -25
View File
@@ -37,6 +37,7 @@ import {
type IdeContext,
type UserTierId,
type UserFeedbackPayload,
type AgentDefinition,
IdeClient,
ideContextStore,
getErrorMessage,
@@ -62,7 +63,14 @@ import {
SessionStartSource,
SessionEndReason,
generateSummary,
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
IdeConnectionEvent,
IdeConnectionType,
logIdeConnection,
type ConnectionConfig,
} from '@google/gemini-cli-core';
import { IdeConnectionSelector } from './components/IdeConnectionSelector.js';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
import { useHistory } from './hooks/useHistoryManager.js';
@@ -94,6 +102,7 @@ import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { keyMatchers, Command } from './keyMatchers.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
@@ -104,6 +113,7 @@ import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useMcpStatus } from './hooks/useMcpStatus.js';
import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
@@ -127,10 +137,10 @@ import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -143,6 +153,16 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
});
}
function isToolAwaitingConfirmation(
pendingHistoryItems: HistoryItemWithoutId[],
) {
return pendingHistoryItems
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
.some((item) =>
item.tools.some((tool) => ToolCallStatus.Confirming === tool.status),
);
}
interface AppContainerProps {
config: Config;
startupWarnings?: string[];
@@ -205,6 +225,8 @@ export const AppContainer = (props: AppContainerProps) => {
null,
);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
const [bannerVisible, setBannerVisible] = useState(true);
@@ -254,6 +276,34 @@ export const AppContainer = (props: AppContainerProps) => {
setPermissionsDialogProps(null);
}, []);
const [isAgentConfigDialogOpen, setIsAgentConfigDialogOpen] = useState(false);
const [selectedAgentName, setSelectedAgentName] = useState<
string | undefined
>();
const [selectedAgentDisplayName, setSelectedAgentDisplayName] = useState<
string | undefined
>();
const [selectedAgentDefinition, setSelectedAgentDefinition] = useState<
AgentDefinition | undefined
>();
const openAgentConfigDialog = useCallback(
(name: string, displayName: string, definition: AgentDefinition) => {
setSelectedAgentName(name);
setSelectedAgentDisplayName(displayName);
setSelectedAgentDefinition(definition);
setIsAgentConfigDialogOpen(true);
},
[],
);
const closeAgentConfigDialog = useCallback(() => {
setIsAgentConfigDialogOpen(false);
setSelectedAgentName(undefined);
setSelectedAgentDisplayName(undefined);
setSelectedAgentDefinition(undefined);
}, []);
const toggleDebugProfiler = useCallback(
() => setShowDebugProfiler((prev) => !prev),
[],
@@ -318,7 +368,9 @@ export const AppContainer = (props: AppContainerProps) => {
if (additionalContext && geminiClient) {
await geminiClient.addHistory({
role: 'user',
parts: [{ text: additionalContext }],
parts: [
{ text: `<hook_context>${additionalContext}</hook_context>` },
],
});
}
}
@@ -371,14 +423,20 @@ export const AppContainer = (props: AppContainerProps) => {
setAdminSettingsChanged(true);
};
const handleAgentsDiscovered = (payload: AgentsDiscoveredPayload) => {
setNewAgents(payload.agents);
};
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged);
coreEvents.on(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
return () => {
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
coreEvents.off(
CoreEvent.AdminSettingsChanged,
handleAdminSettingsChanged,
);
coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
};
}, []);
@@ -485,7 +543,7 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError,
apiKeyDefaultValue,
reloadApiKey,
} = useAuthCommand(settings, config);
} = useAuthCommand(settings, config, initializationResult.authError);
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
{},
);
@@ -507,6 +565,7 @@ export const AppContainer = (props: AppContainerProps) => {
historyManager,
userTier,
setModelSwitchedFromQuotaError,
onShowAuthSelection: () => setAuthState(AuthState.Updating),
});
// Derive auth state variables for backward compatibility with UIStateContext
@@ -516,7 +575,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Session browser and resume functionality
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
const { loadHistoryForResume } = useSessionResume({
const { loadHistoryForResume, isResuming } = useSessionResume({
config,
historyManager,
refreshStatic,
@@ -556,6 +615,9 @@ export const AppContainer = (props: AppContainerProps) => {
await config.refreshAuth(authType);
setAuthState(AuthState.Authenticated);
} catch (e) {
if (e instanceof ChangeAuthRequestedError) {
return;
}
onAuthError(
`Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`,
);
@@ -678,6 +740,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openAgentConfigDialog,
openPermissionsDialog,
quit: (messages: HistoryItem[]) => {
setQuittingMessages(messages);
@@ -691,7 +754,41 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
setText: (text: string) => buffer.setText(text),
promptIdeConnection: async () => {
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
const connections = await ideClient.discoverAvailableConnections();
if (connections.length > 1) {
setAvailableIdeConnections(connections);
} else if (connections.length === 1) {
await ideClient.connect();
logIdeConnection(
config,
new IdeConnectionEvent(IdeConnectionType.START),
);
// Show success message
historyManager.addItem(
{
type: MessageType.INFO,
text: `Connected to IDE: ${connections[0].ideInfo?.displayName || 'Unknown IDE'}`,
},
Date.now(),
);
} else {
// Show error message
historyManager.addItem(
{
type: MessageType.ERROR,
text: 'No IDE connections found.',
},
Date.now(),
);
}
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
setAuthState,
openThemeDialog,
@@ -699,6 +796,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openAgentConfigDialog,
setQuittingMessages,
setDebugMessage,
setShowPrivacyNotice,
@@ -707,6 +805,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openPermissionsDialog,
addConfirmUpdateExtensionRequest,
toggleDebugProfiler,
buffer,
],
);
@@ -814,6 +913,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
pendingToolCalls,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
@@ -845,15 +945,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
lastOutputTimeRef.current = lastOutputTime;
}, [lastOutputTime]);
const isShellAwaitingFocus =
!!activePtyId &&
!embeddedShellFocused &&
config.isInteractiveShellEnabled();
const showShellActionRequired = useInactivityTimer(
isShellAwaitingFocus,
const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({
activePtyId,
lastOutputTime,
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
);
streamingState,
pendingToolCalls,
embeddedShellFocused,
isInteractiveShellEnabled: config.isInteractiveShellEnabled(),
});
const shouldShowActionRequiredTitle = inactivityStatus === 'action_required';
const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working';
// Auto-accept indicator
const showApprovalModeIndicator = useApprovalModeIndicator({
@@ -863,6 +965,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isActive: !embeddedShellFocused,
});
const { isMcpReady } = useMcpStatus(config);
const {
messageQueue,
addMessage,
@@ -873,6 +977,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isConfigInitialized,
streamingState,
submitQuery,
isMcpReady,
});
cancelHandlerRef.current = useCallback(
@@ -881,8 +986,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
];
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
return; // Don't clear - user may be composing a follow-up message
}
if (isToolExecuting(pendingHistoryItems)) {
buffer.setText(''); // Just clear the prompt
buffer.setText(''); // Clear for Ctrl+C cancellation
return;
}
@@ -911,10 +1019,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
addMessage(submittedValue);
const isSlash = isSlashCommand(submittedValue.trim());
const isIdle = streamingState === StreamingState.Idle;
if (isSlash || (isIdle && isMcpReady)) {
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
if (isIdle && !isMcpReady && messageQueue.length === 0) {
coreEvents.emitFeedback(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
);
}
addMessage(submittedValue);
}
addInput(submittedValue); // Track input for up-arrow history
},
[addMessage, addInput],
[
addMessage,
addInput,
submitQuery,
isMcpReady,
streamingState,
messageQueue.length,
],
);
const handleClearScreen = useCallback(() => {
@@ -934,8 +1063,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
* - Any future streaming states not explicitly allowed
*/
const isInputActive =
isConfigInitialized &&
!initError &&
!isProcessing &&
!isResuming &&
!!slashCommands &&
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding) &&
@@ -1235,13 +1366,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
[handleSlashCommand, settings],
);
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({
streamingState,
settings.merged.ui.customWittyPhrases,
!!activePtyId && !embeddedShellFocused,
lastOutputTime,
shouldShowFocusHint,
retryStatus,
);
});
const handleGlobalKeypress = useCallback(
(key: Key) => {
@@ -1371,7 +1500,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
const paddedTitle = computeTerminalTitle({
streamingState,
thoughtSubject: thought?.subject,
isConfirming: !!confirmationRequest || showShellActionRequired,
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
isSilentWorking: shouldShowSilentWorkingTitle,
folderName: basename(config.getTargetDir()),
showThoughts: !!settings.merged.ui.showStatusInTitle,
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
@@ -1387,7 +1517,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
streamingState,
thought,
confirmationRequest,
showShellActionRequired,
shouldShowActionRequiredTitle,
shouldShowSilentWorkingTitle,
settings.merged.ui.showStatusInTitle,
settings.merged.ui.dynamicWindowTitle,
settings.merged.ui.hideWindowTitle,
@@ -1471,6 +1602,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
isAgentConfigDialogOpen ||
isPermissionsDialogOpen ||
isAuthenticating ||
isAuthDialogOpen ||
@@ -1480,8 +1612,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
!!proQuotaRequest ||
!!validationRequest ||
isSessionBrowserOpen ||
isAuthDialogOpen ||
authState === AuthState.AwaitingApiKeyInput;
authState === AuthState.AwaitingApiKeyInput ||
!!newAgents;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1564,6 +1696,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
selectedAgentDisplayName,
selectedAgentDefinition,
isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands,
@@ -1583,6 +1719,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
inputWidth,
suggestionsWidth,
isInputActive,
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
isTrustedFolder,
@@ -1639,6 +1776,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
adminSettingsChanged,
newAgents,
}),
[
isThemeDialogOpen,
@@ -1656,6 +1794,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
selectedAgentDisplayName,
selectedAgentDefinition,
isPermissionsDialogOpen,
permissionsDialogProps,
slashCommands,
@@ -1675,6 +1817,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
inputWidth,
suggestionsWidth,
isInputActive,
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen,
isTrustedFolder,
@@ -1734,6 +1877,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
config,
settingsNonce,
adminSettingsChanged,
newAgents,
],
);
@@ -1755,6 +1899,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openAgentConfigDialog,
closeAgentConfigDialog,
openPermissionsDialog,
closePermissionsDialog,
setShellModeActive,
@@ -1780,9 +1926,38 @@ Logging in with Google... Restarting Gemini CLI to continue.
setEmbeddedShellFocused,
setAuthContext,
handleRestart: async () => {
if (process.send) {
const remoteSettings = config.getRemoteAdminSettings();
if (remoteSettings) {
process.send({
type: 'admin-settings-update',
settings: remoteSettings,
});
}
}
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
},
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
const registry = config.getAgentRegistry();
try {
await Promise.all(
newAgents.map((agent) => registry.acknowledgeAgent(agent)),
);
} catch (error) {
debugLogger.error('Failed to acknowledge agents:', error);
historyManager.addItem(
{
type: MessageType.ERROR,
text: `Failed to acknowledge agents: ${getErrorMessage(error)}`,
},
Date.now(),
);
}
}
setNewAgents(null);
},
}),
[
handleThemeSelect,
@@ -1796,6 +1971,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openAgentConfigDialog,
closeAgentConfigDialog,
openPermissionsDialog,
closePermissionsDialog,
setShellModeActive,
@@ -1820,9 +1997,49 @@ Logging in with Google... Restarting Gemini CLI to continue.
setBannerVisible,
setEmbeddedShellFocused,
setAuthContext,
newAgents,
config,
historyManager,
],
);
// ... (existing imports)
// Inside AppContainer function:
// ... (existing state)
const [availableIdeConnections, setAvailableIdeConnections] = useState<
| Array<ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }>
| undefined
>(initializationResult.availableIdeConnections);
// ... (existing effects/hooks)
if (availableIdeConnections && availableIdeConnections.length > 0) {
return (
<IdeConnectionSelector
connections={availableIdeConnections}
onSelect={async (
conn: ConnectionConfig & {
workspacePath?: string;
ideInfo?: IdeInfo;
},
) => {
const ideClient = await IdeClient.getInstance();
await ideClient.connect({ connectionConfig: conn });
logIdeConnection(
config,
new IdeConnectionEvent(IdeConnectionType.START),
);
setAvailableIdeConnections(undefined);
}}
onCancel={() => {
setAvailableIdeConnections(undefined);
}}
/>
);
}
if (authState === AuthState.AwaitingGoogleLoginRestart) {
return (
<LoginWithGoogleRestartDialog
@@ -1830,6 +2047,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
setAuthContext({});
setAuthState(AuthState.Updating);
}}
config={config}
/>
);
}
@@ -3,7 +3,44 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"MainContent
Notifications
Composer"
Composer
"
`;
exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
@@ -14,8 +51,87 @@ Composer"
`;
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"Notifications
Footer
MainContent
DialogManager"
"MainContent
Notifications
DialogManager
"
`;
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"MainContent
Notifications
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1 │
│ │
│ ? ls list directory │
│ │
│ ls │
│ │
│ Allow execution of: 'ls'? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Composer
"
`;
@@ -34,7 +34,7 @@ vi.mock('../components/shared/text-buffer.js', () => ({
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({
mainAreaWidth: 80,
terminalWidth: 80,
})),
}));
+2 -2
View File
@@ -28,8 +28,8 @@ export function ApiAuthDialog({
error,
defaultValue = '',
}: ApiAuthDialogProps): React.JSX.Element {
const { mainAreaWidth } = useUIState();
const viewportWidth = mainAreaWidth - 8;
const { terminalWidth } = useUIState();
const viewportWidth = terminalWidth - 8;
const pendingPromise = useRef<{ cancel: () => void } | null>(null);
+13 -10
View File
@@ -74,11 +74,12 @@ describe('AuthDialog', () => {
onAuthError: (error: string | null) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
};
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
process.env = {};
vi.stubEnv('CLOUD_SHELL', undefined as unknown as string);
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', undefined as unknown as string);
vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', undefined as unknown as string);
vi.stubEnv('GEMINI_API_KEY', undefined as unknown as string);
props = {
config: {
@@ -100,7 +101,7 @@ describe('AuthDialog', () => {
});
afterEach(() => {
process.env = originalEnv;
vi.unstubAllEnvs();
});
describe('Environment Variable Effects on Auth Options', () => {
@@ -138,7 +139,9 @@ describe('AuthDialog', () => {
])(
'correctly shows/hides COMPUTE_ADC options $desc',
({ env, shouldContain, shouldNotContain }) => {
process.env = { ...env };
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value as string);
}
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
for (const item of shouldContain) {
@@ -178,14 +181,14 @@ describe('AuthDialog', () => {
},
{
setup: () => {
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI);
},
expected: AuthType.USE_GEMINI,
desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var',
},
{
setup: () => {
process.env['GEMINI_API_KEY'] = 'test-key';
vi.stubEnv('GEMINI_API_KEY', 'test-key');
},
expected: AuthType.USE_GEMINI,
desc: 'from GEMINI_API_KEY env var',
@@ -243,7 +246,7 @@ describe('AuthDialog', () => {
it('skips API key dialog on initial setup if env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
renderWithProviders(<AuthDialog {...props} />);
@@ -258,7 +261,7 @@ describe('AuthDialog', () => {
it('skips API key dialog if env var is present but empty', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = ''; // Empty string
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
renderWithProviders(<AuthDialog {...props} />);
@@ -288,7 +291,7 @@ describe('AuthDialog', () => {
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// Simulate that the user has already authenticated once
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
@@ -10,6 +10,7 @@ import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js'
import { useKeypress } from '../hooks/useKeypress.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { type Config } from '@google/gemini-cli-core';
// Mocks
vi.mock('../hooks/useKeypress.js', () => ({
@@ -29,6 +30,10 @@ describe('LoginWithGoogleRestartDialog', () => {
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const mockConfig = {
getRemoteAdminSettings: vi.fn(),
} as unknown as Config;
beforeEach(() => {
vi.clearAllMocks();
exitSpy.mockClear();
@@ -37,13 +42,21 @@ describe('LoginWithGoogleRestartDialog', () => {
it('renders correctly', () => {
const { lastFrame } = render(
<LoginWithGoogleRestartDialog onDismiss={onDismiss} />,
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('calls onDismiss when escape is pressed', () => {
render(<LoginWithGoogleRestartDialog onDismiss={onDismiss} />);
render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -62,7 +75,12 @@ describe('LoginWithGoogleRestartDialog', () => {
async (keyName) => {
vi.useFakeTimers();
render(<LoginWithGoogleRestartDialog onDismiss={onDismiss} />);
render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -12,10 +13,12 @@ import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
interface LoginWithGoogleRestartDialogProps {
onDismiss: () => void;
config: Config;
}
export const LoginWithGoogleRestartDialog = ({
onDismiss,
config,
}: LoginWithGoogleRestartDialogProps) => {
useKeypress(
(key) => {
@@ -23,6 +26,15 @@ export const LoginWithGoogleRestartDialog = ({
onDismiss();
} else if (key.name === 'r' || key.name === 'R') {
setTimeout(async () => {
if (process.send) {
const remoteSettings = config.getRemoteAdminSettings();
if (remoteSettings) {
process.send({
type: 'admin-settings-update',
settings: remoteSettings,
});
}
}
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}, 100);
+7 -3
View File
@@ -34,12 +34,16 @@ export function validateAuthMethodWithSettings(
return validateAuthMethod(authType);
}
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
export const useAuthCommand = (
settings: LoadedSettings,
config: Config,
initialAuthError: string | null = null,
) => {
const [authState, setAuthState] = useState<AuthState>(
AuthState.Unauthenticated,
initialAuthError ? AuthState.Updating : AuthState.Unauthenticated,
);
const [authError, setAuthError] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(initialAuthError);
const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState<
string | undefined
>(undefined);
@@ -39,6 +39,7 @@ describe('aboutCommand', () => {
config: {
getModel: vi.fn(),
getIdeMode: vi.fn().mockReturnValue(true),
getUserTierName: vi.fn().mockReturnValue(undefined),
},
settings: {
merged: {
@@ -97,6 +98,7 @@ describe('aboutCommand', () => {
gcpProject: 'test-gcp-project',
ideClient: 'test-ide',
userEmail: 'test-email@example.com',
tier: undefined,
});
});
@@ -156,4 +158,21 @@ describe('aboutCommand', () => {
}),
);
});
it('should display the tier when getUserTierName returns a value', async () => {
vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue(
'Enterprise Tier',
);
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
tier: 'Enterprise Tier',
}),
);
});
});
@@ -44,6 +44,8 @@ export const aboutCommand: SlashCommand = {
});
const userEmail = cachedAccount ?? undefined;
const tier = context.services.config?.getUserTierName();
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
type: MessageType.ABOUT,
cliVersion,
@@ -54,6 +56,7 @@ export const aboutCommand: SlashCommand = {
gcpProject,
ideClient,
userEmail,
tier,
};
context.ui.addItem(aboutItem);
@@ -218,6 +218,21 @@ describe('agentsCommand', () => {
});
});
it('should show an error if config is not available for enable', async () => {
const contextWithoutConfig = createMockCommandContext({
services: { config: null },
});
const enableCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'enable',
);
const result = await enableCommand!.action!(contextWithoutConfig, 'test');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
it('should disable an agent successfully', async () => {
const reloadSpy = vi.fn().mockResolvedValue(undefined);
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
@@ -308,4 +323,137 @@ describe('agentsCommand', () => {
content: 'Usage: /agents disable <agent-name>',
});
});
it('should show an error if config is not available for disable', async () => {
const contextWithoutConfig = createMockCommandContext({
services: { config: null },
});
const disableCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'disable',
);
const result = await disableCommand!.action!(contextWithoutConfig, 'test');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
describe('config sub-command', () => {
it('should return dialog action for a valid agent', async () => {
const mockDefinition = {
name: 'test-agent',
displayName: 'Test Agent',
description: 'test desc',
kind: 'local',
};
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
expect(configCommand).toBeDefined();
const result = await configCommand!.action!(mockContext, 'test-agent');
expect(result).toEqual({
type: 'dialog',
dialog: 'agentConfig',
props: {
name: 'test-agent',
displayName: 'Test Agent',
definition: mockDefinition,
},
});
});
it('should use name as displayName if displayName is missing', async () => {
const mockDefinition = {
name: 'test-agent',
description: 'test desc',
kind: 'local',
};
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDiscoveredDefinition: vi.fn().mockReturnValue(mockDefinition),
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
const result = await configCommand!.action!(mockContext, 'test-agent');
expect(result).toEqual({
type: 'dialog',
dialog: 'agentConfig',
props: {
name: 'test-agent',
displayName: 'test-agent', // Falls back to name
definition: mockDefinition,
},
});
});
it('should show error if agent is not found', async () => {
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDiscoveredDefinition: vi.fn().mockReturnValue(undefined),
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
const result = await configCommand!.action!(mockContext, 'non-existent');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: "Agent 'non-existent' not found.",
});
});
it('should show usage error if no agent name provided', async () => {
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
const result = await configCommand!.action!(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /agents config <agent-name>',
});
});
it('should show an error if config is not available', async () => {
const contextWithoutConfig = createMockCommandContext({
services: { config: null },
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
const result = await configCommand!.action!(contextWithoutConfig, 'test');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
it('should provide completions for discovered agents', async () => {
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getAllDiscoveredAgentNames: vi
.fn()
.mockReturnValue(['agent1', 'agent2', 'other']),
});
const configCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'config',
);
expect(configCommand?.completion).toBeDefined();
const completions = await configCommand!.completion!(mockContext, 'age');
expect(completions).toEqual(['agent1', 'agent2']);
});
});
});
+86 -2
View File
@@ -62,7 +62,13 @@ async function enableAction(
args: string,
): Promise<SlashCommandActionReturn | void> {
const { config, settings } = context.services;
if (!config) return;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const agentName = args.trim();
if (!agentName) {
@@ -132,7 +138,13 @@ async function disableAction(
args: string,
): Promise<SlashCommandActionReturn | void> {
const { config, settings } = context.services;
if (!config) return;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const agentName = args.trim();
if (!agentName) {
@@ -200,6 +212,59 @@ async function disableAction(
};
}
async function configAction(
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn | void> {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
const agentName = args.trim();
if (!agentName) {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /agents config <agent-name>',
};
}
const agentRegistry = config.getAgentRegistry();
if (!agentRegistry) {
return {
type: 'message',
messageType: 'error',
content: 'Agent registry not found.',
};
}
const definition = agentRegistry.getDiscoveredDefinition(agentName);
if (!definition) {
return {
type: 'message',
messageType: 'error',
content: `Agent '${agentName}' not found.`,
};
}
const displayName = definition.displayName || agentName;
return {
type: 'dialog',
dialog: 'agentConfig',
props: {
name: agentName,
displayName,
definition,
},
};
}
function completeAgentsToEnable(context: CommandContext, partialArg: string) {
const { config, settings } = context.services;
if (!config) return [];
@@ -221,6 +286,15 @@ function completeAgentsToDisable(context: CommandContext, partialArg: string) {
return allAgents.filter((name: string) => name.startsWith(partialArg));
}
function completeAllAgents(context: CommandContext, partialArg: string) {
const { config } = context.services;
if (!config) return [];
const agentRegistry = config.getAgentRegistry();
const allAgents = agentRegistry?.getAllDiscoveredAgentNames() ?? [];
return allAgents.filter((name: string) => name.startsWith(partialArg));
}
const enableCommand: SlashCommand = {
name: 'enable',
description: 'Enable a disabled agent',
@@ -239,6 +313,15 @@ const disableCommand: SlashCommand = {
completion: completeAgentsToDisable,
};
const configCommand: SlashCommand = {
name: 'config',
description: 'Configure an agent',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: configAction,
completion: completeAllAgents,
};
const agentsRefreshCommand: SlashCommand = {
name: 'refresh',
description: 'Reload the agent registry',
@@ -278,6 +361,7 @@ export const agentsCommand: SlashCommand = {
agentsRefreshCommand,
enableCommand,
disableCommand,
configCommand,
],
action: async (context: CommandContext, args) =>
// Default to list if no subcommand is provided
@@ -11,7 +11,7 @@ import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getVersion } from '@google/gemini-cli-core';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { formatBytes } from '../utils/formatters.js';
// Mock dependencies
vi.mock('open');
@@ -68,7 +68,7 @@ vi.mock('../utils/terminalCapabilityManager.js', () => ({
describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getVersion).mockResolvedValue('0.1.0');
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
vi.mocked(formatBytes).mockReturnValue('100 MB');
vi.stubEnv('SANDBOX', 'gemini-test');
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));

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