Compare commits

...

174 Commits

Author SHA1 Message Date
gemini-cli-robot e1ea24806e chore(release): v0.19.0-preview.0 2025-11-26 02:44:48 +00:00
Billy Biggs d2a6cff4df Update dependency for modelcontextprotocol/sdk to 1.23.0 (#13827) 2025-11-26 01:23:13 +00:00
Aarushi Shah acf5ed595f Add Databricks auth support and custom header option to gemini cli (#11893)
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
2025-11-26 00:22:47 +00:00
Pato Beltran 03845198ce Update Code Wiki README badge (#13768)
Co-authored-by: Pato Beltran <patobeltran@google.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-11-25 23:52:41 +00:00
Sehoon Shon d8a3d08f8e fallback to flash for TerminalQuota errors (#13791) 2025-11-25 21:17:22 +00:00
Daniel Alejandro Coll Tejeda 6f9118dca6 Fix TypeError: "URL.parse is not a function" for Node.js < v22 (#13698) 2025-11-25 20:18:49 +00:00
bl-ue d0b6701fba docs(sessions): add documentation for chat recording and session management (#13667) 2025-11-25 18:55:29 +00:00
bl-ue 94c3eecb99 feat(sessions): add /resume slash command to open the session browser (#13621) 2025-11-25 18:54:09 +00:00
bl-ue 098e5c281c fix(resume): allow passing a prompt via stdin while resuming using --resume (#13520) 2025-11-25 17:53:17 +00:00
Maksym f2c52f777c fix(cli): allow non-GitHub SCP-styled URLs for extension installation (#13800)
Co-authored-by: Maksym Kursin <maksym.kursin@dkatalis.com>
2025-11-25 16:57:53 +00:00
Scott Wu 2b41263aa7 fix: Add $schema property to settings.schema.json (#12763)
Co-authored-by: cornmander <shikhman@google.com>
2025-11-25 04:48:47 +00:00
Adam Weidman 404a4468d8 chore: remove console.error (#13779) 2025-11-25 04:24:51 +00:00
gemini-cli-robot 7f67c7f952 chore/release: bump version to 0.19.0-nightly.20251125.f6d97d448 (#13782) 2025-11-25 02:42:52 +00:00
Adam Weidman c8540b5744 feat(config): add isModelAvailabilityServiceEnabled setting (#13777) 2025-11-25 01:26:47 +00:00
Edilmo Palencia f6d97d4488 feat(hooks): Hook System Orchestration (#9102) 2025-11-24 23:24:18 +00:00
Adam Weidman 87712a0a7c feat(model-availability): introduce ModelPolicy and PolicyCatalog (#13751) 2025-11-24 23:08:10 +00:00
joshualitt ba0e053ffc bug(core): Add default chat compression config. (#13766) 2025-11-24 23:02:00 +00:00
joshualitt d14779b264 feat(core): Land bool for alternate system prompt. (#13764) 2025-11-24 22:34:44 +00:00
Edilmo Palencia 5411f4a667 feat(hooks): Hook Agent Lifecycle Integration (#9105) 2025-11-24 22:31:48 +00:00
Edilmo Palencia 2034098780 feat(hooks): Hook Event Handling (#9097) 2025-11-24 21:51:39 +00:00
grMLEqomlkkU5Eeinz4brIrOVCUCkJuN d53a5c4fb5 fix: (some minor improvements to configs and getPackageJson return behaviour) (#12510)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2025-11-24 21:20:23 +00:00
Edilmo Palencia f6ee025c46 feat(hooks): Hook Telemetry Infrastructure (#9082) 2025-11-24 21:13:24 +00:00
joshualitt e50bf6adad feat(core): Migrate chatCompressionService to model configs. (#12863) 2025-11-24 20:24:45 +00:00
Adib234 c21b6899e1 Add session subtask in /stats command (#13750) 2025-11-24 19:01:41 +00:00
Megha Bansal 95693e265e Improve code coverage for cli package (#13724) 2025-11-24 17:41:46 +00:00
JAYADITYA 569c6f1dd0 feat :rephrasing the extension logging messages to run the explore command when there are no extensions installed (#13740) 2025-11-24 15:54:15 +00:00
Kimihiko Kitase 7350399a50 fix(core): Fix context window overflow warning for PDF files (#13548) 2025-11-24 07:46:03 +00:00
gemini-cli-robot c2a741ee36 chore/release: bump version to 0.19.0-nightly.20251124.e177314a4 (#13713) 2025-11-24 02:16:24 +00:00
gemini-cli-robot e177314a4a chore/release: bump version to 0.19.0-nightly.20251123.dadd606c0 (#13675) 2025-11-23 01:53:21 +00:00
gemini-cli-robot dadd606c0d chore/release: bump version to 0.19.0-nightly.20251122.42c2e1b21 (#13637) 2025-11-22 07:46:20 +00:00
Jacob Richman b3fcddde11 Update ink version to 6.4.6 (#13631) 2025-11-22 03:20:28 +00:00
Megha Bansal e205a468d9 Improve test coverage for cli/src/ui/components (#13598) 2025-11-22 02:47:29 +00:00
christine betts bdf80ea7c0 fix(cli): Prevent stdout/stderr patching for extension commands (#13600)
Co-authored-by: jacob314 <jacob314@gmail.com>
2025-11-22 02:08:06 +00:00
Jacob Richman 5e218a5630 Turn off alternate buffer mode by default. (#13623) 2025-11-22 01:30:38 +00:00
joshualitt 8c36b10682 feat(core): Add BaseLlmClient.generateContent. (#13591) 2025-11-22 01:27:57 +00:00
joshualitt 42c2e1b217 feat(core): Add support for custom aliases for model configs. (#13546) 2025-11-22 00:13:10 +00:00
Tommaso Sciortino 1e715d1e5c Restore bracketed paste mode after external editor exit (#13606) 2025-11-21 21:20:37 +00:00
owenofbrien 9f9a2fa844 Moved client id logging into createBasicLogEvent (#13607) 2025-11-21 21:20:15 +00:00
owenofbrien 64eb14ab11 Fix exp id logging (#13430) 2025-11-21 20:44:50 +00:00
joshualitt 0713c86dec feat(docs): Ensure multiline JS objects are rendered properly. (#13535) 2025-11-21 17:59:34 +00:00
Adib234 99c5bf2e97 Remove useModelRouter experimental flag (#13593) 2025-11-21 17:54:17 +00:00
joshualitt fe67ef63c1 bug(core): Ensure we use thinking budget on fallback to 2.5 (#13596) 2025-11-21 17:43:20 +00:00
JAYADITYA 19d4384f16 docs: Update uninstall command to reflect multiple extension support (#13582) 2025-11-21 17:38:59 +00:00
Jack Wotherspoon d351f07758 feat: custom loading phrase when interactive shell requires input (#12535) 2025-11-21 17:19:34 +00:00
noahacgn d72f35c2e7 docs: fix grammar typo "a MCP" to "an MCP" (#13595) 2025-11-21 16:59:45 +00:00
Adam Weidman aeffa2a460 feat(core): add modelAvailabilityService for managing and tracking model health (#13426) 2025-11-21 16:54:16 +00:00
Jacob Richman 030a5ace97 Fix multiple bugs with auth flow including using the implemented but unused restart support. (#13565) 2025-11-21 16:31:47 +00:00
bl-ue b97661553f feat(ui): build interactive session browser component (#13351) 2025-11-21 16:16:56 +00:00
Megha Bansal 3370644ffe Improved code coverage for cli/src/zed-integration (#13570) 2025-11-21 15:38:33 +00:00
fancivez 0f0b463a2f docs: fix typos in source code and documentation (#13577) 2025-11-21 14:59:42 +00:00
Megha Bansal 8d082a904d Improve code coverage for cli/src/ui/privacy package (#13493) 2025-11-21 06:34:30 +00:00
Megha Bansal 61582678bf Optimize and improve test coverage for cli/src/config (#13485) 2025-11-21 04:57:59 +00:00
Pyry Takala 613b8a4527 fix(core): correct bash @P prompt transformation detection (#13544) 2025-11-21 00:34:47 +00:00
Sandy Tao 5982abeffb fix(ui): Correct mouse click cursor positioning for wide characters (#13537) 2025-11-20 23:56:31 +00:00
Vish (Ishaya) Abrams 78b10dccf1 Skip pre-commit hooks for shadow repo (#13331) (#13488)
Co-authored-by: Vishvananda Abrams <vabrams@google.com>
2025-11-20 22:50:36 +00:00
gemini-cli-robot 85bc25a765 chore(release): bump version to 0.19.0-nightly.20251120.8e531dc02 (#13540) 2025-11-20 22:43:11 +00:00
Jacob Richman fec0eba07e move stdio (#13528) 2025-11-20 22:16:46 +00:00
Tommaso Sciortino f92e79eba0 update docs for http proxy (#13538) 2025-11-20 22:13:59 +00:00
Adib234 3e50be1658 Update persistence state to track counts of messages instead of times banner has been displayed (#13428) 2025-11-20 21:57:06 +00:00
cornmander 9937fb2220 Use lenient MCP output schema validator (#13521) 2025-11-20 21:51:25 +00:00
joshualitt 8e531dc029 feat(core): Incorporate Gemini 3 into model config hierarchy. (#13447) 2025-11-20 21:07:14 +00:00
Ben Brandt 83d0bdc32e feat(zed-integration): Use default model routing for Zed integration (#13398)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2025-11-20 20:08:37 +00:00
Tommaso Sciortino d15970e12c remove duplicated mouse code (#13525) 2025-11-20 11:46:53 -08:00
Tommaso Sciortino c7b5dcd28f Change default compress threshold to 0.5 for api key users (#13517) 2025-11-20 11:43:35 -08:00
joshualitt 8c07ad2ab9 feat(core): Use thinking level for Gemini 3 (#13445) 2025-11-20 19:19:52 +00:00
Adib234 ade9dfeebb Enable switching preview features on/off without restart (#13515) 2025-11-20 19:00:16 +00:00
Jacob Richman d1e35f8660 Protect stdout and stderr so JavaScript code can't accidentally write to stdout corrupting ink rendering (#13247)
Bypassing rules as link checker failure is spurious.
2025-11-20 10:44:02 -08:00
Adib234 e20d282088 Update banner design (#13420) 2025-11-20 16:52:15 +00:00
Gaurav Sehgal 4adfdad47f Copy commands as part of setup-github (#13464) 2025-11-20 16:05:15 +00:00
Ben Brandt 6c126b9e58 fix(zed-integration): Ensure that the zed integration is classified as interactive (#13394)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2025-11-20 15:28:37 +00:00
lifefloating 1d2e27a698 Fix: Update system instruction when GEMINI.md memory is loaded or refreshed (#12136) 2025-11-20 13:22:06 +00:00
Adam Weidman 049a299b92 Revert "feat(core): Fix bug with incorrect model overriding." (#13483) 2025-11-20 06:39:21 +00:00
Sandy Tao b1258dd52c fix(cli): prevent race condition when restoring prompt after context overflow (#13473) 2025-11-20 05:54:16 +00:00
Greg Shikhman 98cdaa01b8 Revert "build bun"
This reverts commit b0938f9102.
2025-11-20 06:02:54 +00:00
Greg Shikhman 3476a97acc build bun 2025-11-20 06:02:54 +00:00
Tommaso Sciortino 9ebf321717 Use synchronous writes when detecting keyboard modes (#13478) 2025-11-20 05:50:05 +00:00
joshualitt ff725dea41 feat(core): Fix bug with incorrect model overriding. (#13477) 2025-11-20 05:48:08 +00:00
gemini-cli-robot c3f1b29c1e chore/release: bump version to 0.18.0-nightly.20251120.2231497b1 (#13476) 2025-11-20 05:05:32 +00:00
joshualitt 257cd07a3a feat(core): Wire up chat code path for model configs. (#12850) 2025-11-20 04:41:16 +00:00
Preston Holmes 43d6dc3668 Add User email detail to about box (#13459) 2025-11-20 03:46:21 +00:00
Gal Zahavi 2231497b1f feat: add click-to-focus support for interactive shell (#13341) 2025-11-19 23:49:39 +00:00
Tommaso Sciortino d0a845b6e6 remove unneeded log (#13456) 2025-11-19 23:42:21 +00:00
kevin-ramdass e4c4bb265e feat: update thinking mode support to exclude gemini-2.0 models and simplify logic. (#13454) 2025-11-19 23:35:35 +00:00
Smetalo 0f845407f1 Fix typo in write_todos methodology instructions (#13411) 2025-11-19 23:29:12 +00:00
Tommaso Sciortino b2a2ea3633 Fix Copyright line in LICENSE (#13449) 2025-11-19 22:53:34 +00:00
niladri das f8a862738d [cli-ui] Keep header ASCII art colored on non-gradient terminals (#13373) (#13374)
Co-authored-by: Niladri Das <bniladridas@users.noreply.github.com>
2025-11-19 22:04:58 +00:00
joshualitt 25f8452127 feat(core, cli): Bump genai version to 1.30.0 (#13435) 2025-11-19 20:52:27 +00:00
Tommaso Sciortino 84573992b4 Restore keyboard mode when exiting the editor (#13350) 2025-11-19 19:37:30 +00:00
Holt Skinner 3f8d636501 docs: Add Code Wiki link to README (#13289) 2025-11-19 18:15:41 +00:00
Ben Brandt 300205b07c fix(zed-integration): Correctly handle cancellation errors (#13399) 2025-11-19 17:25:52 +00:00
bl-ue e1c711f5ba feat(sessions): record interactive-only errors and warnings to chat recording JSON files (#13300) 2025-11-19 16:32:13 +00:00
Jainam M 282654e7b8 Capturing github action workflow name if present and send it to clearcut (#13132) 2025-11-19 16:30:03 +00:00
bl-ue 0d89ac7406 Followup from #10719 (#13243) 2025-11-19 16:22:17 +00:00
davidmcwherter 5c47592159 Refactor createTransport to duplicate less code (#13010)
Co-authored-by: David McWherter <davidmcw@gmail.com>
2025-11-19 15:23:01 +00:00
Jack Wotherspoon 61f0f3c243 fix: allow MCP prompts with spaces in name (#12910)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-11-19 14:44:30 +00:00
avilladsen 1e8ae5b9d7 fix(cli): fix crash on startup in NO_COLOR mode (#13343) due to ungua… (#13352) 2025-11-19 04:18:18 +00:00
Jacob Richman e8d0e0d342 bug(ui) showLineNumbers had the wrong default value. (#13356) 2025-11-19 03:33:40 +00:00
Sandy Tao b644f037a3 fix(ui): Clear input prompt on Escape key press (#13335) 2025-11-19 03:11:36 +00:00
Gal Zahavi c5498bbb07 Improve pty resize error handling for Windows (#13353) 2025-11-19 02:08:01 +00:00
Tommaso Sciortino 90c764ce13 Support 3-parameter modifyOtherKeys sequences (#13342) 2025-11-19 00:40:40 +00:00
joshualitt 10003a6490 fix(core): Ensure read_many_files tool is available to zed. (#13338) 2025-11-18 22:58:12 +00:00
Tommaso Sciortino b916d79fe2 Improve keyboard code parsing (#13307) 2025-11-18 19:49:08 +00:00
Gray Mackall 36b0a86c69 Fix links in Gemini 3 Pro documentation (#13312) 2025-11-18 19:39:51 +00:00
Jenna Inouye 7cc5234b9c Docs: Access clarification. (#13304) 2025-11-18 19:31:51 +00:00
Shreya Keshive 379f09d92f chore(release): v0.18.0-nightly.20251118.86828bb56 (#13309) 2025-11-18 19:04:29 +00:00
Aswin Ashok fd9d3e19a9 Remove obsolete reference to "help wanted" label in CONTRIBUTING.md (#13291) 2025-11-18 18:17:07 +00:00
Shreya Keshive 86828bb561 feat: launch Gemini 3 in Gemini CLI 🚀🚀🚀 (in main) (#13287)
Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
Co-authored-by: Sehoon Shon <sshon@google.com>
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Aishanee Shah <aishaneeshah@gmail.com>
Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: joshualitt <joshualitt@google.com>
Co-authored-by: Jenna Inouye <jinouye@google.com>
2025-11-18 09:01:16 -08:00
Jenna Inouye 78075c8a37 Docs: Add changelog for v.0.15.0 (#13276) 2025-11-18 16:09:55 +00:00
JAYADITYA 7d33baabe4 feat :uninstall multiple extensions (#13016)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-18 15:20:25 +00:00
JAYADITYA ecf8fba10e feat: Show tip on first request and refactor phrases (#12952)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-11-18 07:02:31 +00:00
Tommaso Sciortino 2f8b68dffd update glob version (#13242) 2025-11-18 00:31:17 +00:00
Tommaso Sciortino 8877c85278 Right click to paste in Alternate Buffer mode (#13234) 2025-11-17 23:48:33 +00:00
Riddhi Dutta 1d1bdc57ce Refactored 4 files of tools package (#13235)
Co-authored-by: riddhi <duttariddhi@google.com>
2025-11-17 19:31:29 +00:00
Tommaso Sciortino ba88707b1e Fix test to not leave terminal in mouse mode (#13232) 2025-11-17 17:47:08 +00:00
cornmander 8c78fe4f10 rework MCP tool discovery and invocation (#13160)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-17 17:03:48 +00:00
Riddhi Dutta 394a7ea019 Refactored 3 files of tools package (#13231)
Co-authored-by: riddhi <duttariddhi@google.com>
2025-11-17 16:54:09 +00:00
Jacob Richman 78a28bfc01 Fix: Animated scrollbar renders black in NO_COLOR mode (#13188) 2025-11-17 04:45:07 +00:00
gemini-cli-robot cf8de02c68 chore/release: bump version to 0.17.0-nightly.20251116.e650a4ee5 (#13154) 2025-11-16 02:32:39 +00:00
Riddhi Dutta e650a4ee59 Refactored core package ut (#13139)
Co-authored-by: riddhi <duttariddhi@google.com>
2025-11-15 17:23:21 +00:00
Samuel Soto c6b6dcbe95 Docs: Clarify Project-Scoped Behavior of Chat Sub-commands (#10458)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-15 15:50:30 +00:00
shrutip90 9786c4dcff Check folder trust before allowing add directory (#12652) 2025-11-15 03:06:30 +00:00
Tommaso Sciortino d03496b710 Increase paste timeout + add warning. (#13099) 2025-11-15 00:02:28 +00:00
Jacob Richman ab6b2293e1 Only warn about mouse movement when the mouse is down. (#13101) 2025-11-14 22:55:05 +00:00
shrutip90 472e775a13 feat: Update permissions command to support modifying trust for other… (#11642) 2025-11-14 22:41:53 +00:00
Adib234 ce56b4ee1b Change flag name to flag id for existing flags (#13073) 2025-11-14 21:34:54 +00:00
Jacob Richman ba15eeb55f bug(ui) make it clear when users need to enter selection mode and fix clear issue. (#13083) 2025-11-14 20:02:15 +00:00
shrutip90 d683e1c0db fix(cli): Exit CLI when trust save unsuccessful during launch (#11968) 2025-11-14 19:56:39 +00:00
Tommaso Sciortino 6d83d3440c Change default compress threshold to 0.7 for api key users (#13079) 2025-11-14 17:13:59 +00:00
Caroline Rose 9d74b7c0e8 feat(auth): Add option for metadata server application default credentials without project override (#12948) 2025-11-14 16:39:11 +00:00
Stone Sun 016b5b42e2 docs: remove references to deprecated --checkpointing flag (#12477)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2025-11-14 16:19:12 +00:00
YongSung Na a591505bf9 docs: remove references to deprecated flags (--checkpointing, --sandbox-image) (#12578)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2025-11-14 15:28:14 +00:00
Megha Bansal 638dd2f6c0 Improve test code coverage for cli/command/extensions package (#12994) 2025-11-14 05:28:01 +00:00
Tommaso Sciortino 555e25e633 slightly adjust model message formatting (#13043) 2025-11-14 05:13:39 +00:00
gemini-cli-robot 3174573b64 chore/release: bump version to 0.17.0-nightly.20251114.0fcbff506 (#13040) 2025-11-14 04:07:27 +00:00
Tommaso Sciortino 2c5e09e1c3 Update comment and undo unnecessary logging (#13025) 2025-11-14 03:11:13 +00:00
Tommaso Sciortino ab11b2c27f Show model in history (#13034) 2025-11-14 03:11:06 +00:00
gemini-cli-robot 0fcbff506e chore(release): bump version to 0.17.0-nightly.20251113.48e3932f6 (#13027) 2025-11-14 00:19:33 +00:00
joshualitt 48e3932f65 feat(core, cli): Add auth type to history checkpoint. (#13023) 2025-11-13 23:13:39 +00:00
Jacob Richman d304216300 Polish sticky headers (#13024) 2025-11-13 22:33:48 +00:00
Jacob Richman 2b8adf8cf3 jacob314/drag scrollbar (#12998) 2025-11-13 22:13:18 +00:00
Jacob Richman fb99b95372 Switch back to truncating headers. Unfortunately we have too many headers that are 10+ lines for no good reason so we need to add back this truncation until we fix them. (#13018) 2025-11-13 19:38:19 +00:00
Jacob Richman 60fe5acd60 feat(ui) support animated page up/down, fn-up/down and end+home (#13012) 2025-11-13 19:16:23 +00:00
Jacob Richman eb9ff72b5a Support incremental update experiment flag. (#12926) 2025-11-13 17:45:03 +00:00
Tommaso Sciortino fb03242950 Improve MALFORMED_FUNCTION_CALL handling (#12965) 2025-11-13 16:07:49 +00:00
Adam Weidman 54c1e13853 chore: update package lock (#12974) 2025-11-13 14:50:14 +00:00
N. Taylor Mullen 102905bbc7 feat: normalize verticality (#12991) 2025-11-13 08:20:14 +00:00
N. Taylor Mullen fe1bfc64f7 feat: disengage surface adhesion protocols (#12989) 2025-11-13 07:36:18 +00:00
Jacob Richman 0f9ec2735c feat(ui) Make useAlternateBuffer the default (#12976) 2025-11-13 05:17:46 +00:00
Abhi 13d8d9477c fix(editor): ensure preferred editor setting updates immediately (#12981) 2025-11-13 04:02:53 +00:00
Tommaso Sciortino 43916b98aa Don't clear buffers on cleanup. (#12979) 2025-11-13 03:20:21 +00:00
Abhi 5d27a62bec refactor: remove read-many-files tool from agent (#12796) 2025-11-13 02:56:37 +00:00
gemini-cli-robot a05e0ea3a4 chore/release: bump version to 0.16.0-nightly.20251113.ad1f0d99 (#12977) 2025-11-13 02:03:52 +00:00
anthony bushong 2987b473dd feat(core): set default chat base model configs (#12951) 2025-11-13 01:01:31 +00:00
Jacob Richman ee7065f665 Sticky headers where the top rounded border is sticky. (#12971) 2025-11-13 01:01:16 +00:00
anthony bushong d26b828ab3 feat(core): update default model config in client.ts (#12960) 2025-11-13 00:52:04 +00:00
Sehoon Shon a810ca80bc Allow users to reset to auto when it's in fallback mode (#12623) 2025-11-13 00:30:43 +00:00
Allen Hutchison ad1f0d995d refactor: move toml-loader.test.ts to use real filesystem (#12969) 2025-11-12 23:17:38 +00:00
Allen Hutchison 1ed163a666 feat(safety): Introduce safety checker framework (#12504) 2025-11-12 21:18:34 +00:00
cornmander aa9922bc98 feat: autogenerate keyboard shortcut docs (#12944) 2025-11-12 21:07:14 +00:00
Tommaso Sciortino 0075b4f118 Always show the tool internal name in /tools (#12964) 2025-11-12 20:45:18 +00:00
Jacob Richman 4d85ce40be Turns out the node console.clear() clears the buffer. (#12959) 2025-11-12 20:15:25 +00:00
anj-s 7ec78452ec Enable write_todo tool and fix output function schema (#12905) 2025-11-12 18:18:15 +00:00
Abhi 540f60696a fix(docs): Release version for read many files removal (#12949) 2025-11-12 18:05:16 +00:00
Shardul Natu 1ffb9c4188 fix(FileCommandLoader): Remove error logs if the operation was aborted (#12927)
Co-authored-by: Shnatu <snatu@google.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-12 16:33:28 +00:00
Shreya Keshive 570ccc7da0 feat: Update client metadata for consistency (#12947) 2025-11-12 16:11:48 +00:00
Edilmo Palencia 1c8fe92d0f feat(hooks): Hook Result Aggregation (#9095) 2025-11-12 07:14:09 +00:00
gemini-cli-robot 396b427cc9 chore/release: bump version to 0.16.0-nightly.20251112.c961f274 (#12923) 2025-11-12 06:13:46 +00:00
gemini-cli-robot c961f27401 chore(release): bump version to 0.16.0-nightly.20251112.2abc288c (#12917)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-11-12 05:14:20 +00:00
Abhi 1c87e7cd25 feat: enhance RipGrep tool with advanced search options and improved defaults (#12677) 2025-11-12 05:11:19 +00:00
Sandy Tao 408b885689 feat(core): enhance loop detection with 2-stage check (#12902) 2025-11-12 04:49:00 +00:00
N. Taylor Mullen cab9b1f370 Fix extensions disable/enable commands not awaiting handler (#12915) 2025-11-12 04:31:46 +00:00
Tommaso Sciortino d3cf28eb4b Use PascalCase for all tool display names (#12918) 2025-11-12 04:28:13 +00:00
Megha Bansal 11a0a9b911 Increase code coverage for core packages (#12872) 2025-11-12 04:06:43 +00:00
Tommaso Sciortino e8038c727f fix test to use faketimer (#12913) 2025-11-12 03:03:10 +00:00
573 changed files with 45912 additions and 11209 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
- name: 'Link Checker'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with:
args: '--verbose ./**/*.md'
args: '--verbose --accept 200,503 ./**/*.md'
fail: true
test_linux:
name: 'Test (Linux)'
+1 -1
View File
@@ -20,4 +20,4 @@ jobs:
id: 'lychee'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with:
args: '--verbose --no-progress ./**/*.md'
args: '--verbose --no-progress --accept 200,503 ./**/*.md'
-4
View File
@@ -70,10 +70,6 @@ augment their manual review process.
### Self assigning issues
If you're looking for an issue to work on, check out our list of issues that are
labeled
["help wanted"](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+state%3Aopen+label%3A%22help+wanted%22).
To assign an issue to yourself, simply add a comment with the text `/assign`.
The comment must contain only that text and nothing else. This command will
assign the issue to you, provided it is not already assigned.
+1 -1
View File
@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1
View File
@@ -4,6 +4,7 @@
[![Gemini CLI E2E](https://github.com/google-gemini/gemini-cli/actions/workflows/e2e.yml/badge.svg)](https://github.com/google-gemini/gemini-cli/actions/workflows/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)
![Gemini CLI Screenshot](./docs/assets/gemini-screenshot.png)
+46
View File
@@ -3,6 +3,52 @@
Wondering what's new in Gemini CLI? This document provides key highlights and
notable changes to Gemini CLI.
## v0.15.0 - Gemini CLI weekly update - 2025-11-03
- **🎉 Seamless scrollable UI & mouse support:** Weve given Gemini CLI a major
facelift to make your terminal experience smoother and much more polished. You
now get a flicker-free display with sticky headers that keep important context
visible and a stable input prompt that doesn't jump around. We even added
mouse support so you can click right where you need to type!
([gif](https://imgur.com/a/O6qc7bx),
[@jacob314](https://github.com/jacob314)).
- **Announcement:**
[https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/](https://developers.googleblog.com/en/making-the-terminal-beautiful-one-pixel-at-a-time/)
- **🎉 New partner extensions:**
- **Arize:** Seamlessly instrument AI applications with Arize AX and grant
direct access to Arize support:
`gemini extensions install https://github.com/Arize-ai/arize-tracing-assistant`
- **Chronosphere:** Retrieve logs, metrics, traces, events, and specific
entities:
`gemini extensions install https://github.com/chronosphereio/chronosphere-mcp`
- **Transmit:** Comprehensive context, validation, and automated fixes for
creating production-ready authentication and identity workflows:
`gemini extensions install https://github.com/TransmitSecurity/transmit-security-journey-builder`
- **Todo planning:** Complex questions now get broken down into todo lists that
the model can manage and check off. ([gif](https://imgur.com/a/EGDfNlZ),
[pr](https://github.com/google-gemini/gemini-cli/pull/12905) by
[@anj-s](https://github.com/anj-s))
- **Disable GitHub extensions:** Users can now prevent the installation and
loading of extensions from GitHub.
([pr](https://github.com/google-gemini/gemini-cli/pull/12838) by
[@kevinjwang1](https://github.com/kevinjwang1)).
- **Extensions restart:** Users can now explicitly restart extensions using the
`/extensions restart` command.
([pr](https://github.com/google-gemini/gemini-cli/pull/12739) by
[@jakemac53](https://github.com/jakemac53)).
- **Better Angular support:** Angular workflows should now be more seamless
([pr](https://github.com/google-gemini/gemini-cli/pull/10252) by
[@MarkTechson](https://github.com/MarkTechson)).
- **Validate command:** Users can now check that local extensions are formatted
correctly. ([pr](https://github.com/google-gemini/gemini-cli/pull/12186) by
[@kevinjwang1](https://github.com/kevinjwang1)).
## v0.12.0 - Gemini CLI weekly update - 2025-10-27
![Codebase investigator subagent in Gemini CLI.](https://i.imgur.com/4J1njsx.png)
+5 -15
View File
@@ -37,22 +37,12 @@ file in your project's temporary directory, typically located at
## Enabling the Feature
The Checkpointing feature is disabled by default. To enable it, you can either
use a command-line flag or edit your `settings.json` file.
The Checkpointing feature is disabled by default. To enable it, you need to edit
your `settings.json` file.
### Using the Command-Line Flag
You can enable checkpointing for the current session by using the
`--checkpointing` flag when starting the Gemini CLI:
```bash
gemini --checkpointing
```
### Using the `settings.json` File
To enable checkpointing by default for all sessions, you need to edit your
`settings.json` file.
> **Note:** The `--checkpointing` command-line flag was removed in version
> 0.11.0. Checkpointing can now only be enabled through the `settings.json`
> configuration file.
Add the following key to your `settings.json`:
+30 -6
View File
@@ -30,8 +30,9 @@ Slash commands provide meta-level control over the CLI itself.
checkpoints are:
- Linux/macOS: `~/.gemini/tmp/<project_hash>/`
- Windows: `C:\Users\<YourUsername>\.gemini\tmp\<project_hash>\`
- When you run `/chat list`, the CLI only scans these specific
directories to find available checkpoints.
- **Behavior:** Chats are saved into a project-specific directory,
determined by where you run the CLI. Consequently, saved chats are
only accessible when working within that same project.
- **Note:** These checkpoints are for manually saving and resuming
conversation states. For automatic checkpoints created before file
modifications, see the
@@ -39,8 +40,14 @@ Slash commands provide meta-level control over the CLI itself.
- **`resume`**
- **Description:** Resumes a conversation from a previous save.
- **Usage:** `/chat resume <tag>`
- **Note:** You can only resume chats that were saved within the current
project. To resume a chat from a different project, you must run the
Gemini CLI from that project's directory.
- **`list`**
- **Description:** Lists available tags for chat state resumption.
- **Note:** This command only lists chats saved within the current
project. Because chat history is project-scoped, chats saved in other
project directories will not be displayed.
- **`delete`**
- **Description:** Deletes a saved conversation checkpoint.
- **Usage:** `/chat delete <tag>`
@@ -154,17 +161,34 @@ Slash commands provide meta-level control over the CLI itself.
edits made by a tool. If run without a tool call ID, it will list available
checkpoints to restore from.
- **Usage:** `/restore [tool_call_id]`
- **Note:** Only available if the CLI is invoked with the `--checkpointing`
option or configured via [settings](../get-started/configuration.md). See
- **Note:** Only available if checkpointing is configured via
[settings](../get-started/configuration.md). See
[Checkpointing documentation](../cli/checkpointing.md) for more details.
- **`/resume`**
- **Description:** Browse and resume previous conversation sessions. Opens an
interactive session browser where you can search, filter, and select from
automatically saved conversations.
- **Features:**
- **Session Browser:** Interactive interface showing all saved sessions with
timestamps, message counts, and first user message for context
- **Search:** Use `/` to search through conversation content across all
sessions
- **Sorting:** Sort sessions by date or message count
- **Management:** Delete unwanted sessions directly from the browser
- **Resume:** Select any session to resume and continue the conversation
- **Note:** All conversations are automatically saved as you chat - no manual
saving required. See [Session Management](../cli/session-management.md) for
complete details.
- **`/settings`**
- [**`/settings`**](./settings.md)
- **Description:** Open the settings editor to view and modify Gemini CLI
settings.
- **Details:** This command provides a user-friendly interface for changing
settings that control the behavior and appearance of Gemini CLI. It is
equivalent to manually editing the `.gemini/settings.json` file, but with
validation and guidance to prevent errors.
validation and guidance to prevent errors. See the
[settings documentation](./settings.md) for a full list of available
settings.
- **Usage:** Simply run `/settings` and the editor will open. You can then
browse or search for specific settings, view their current values, and
modify them as desired. Changes to some settings are applied immediately,
+15 -5
View File
@@ -464,6 +464,18 @@ the `excludedProjectEnvVars` setting in your `settings.json` file.
- Specifies the default Gemini model to use.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-2.5-flash"`
- **`GEMINI_CLI_CUSTOM_HEADERS`**:
- Adds extra HTTP headers to Gemini API and Code Assist requests.
- Accepts a comma-separated list of `Name: value` pairs.
- Example:
`export GEMINI_CLI_CUSTOM_HEADERS="X-My-Header: foo, X-Trace-ID: abc123"`.
- **`GEMINI_API_KEY_AUTH_MECHANISM`**:
- Specifies how the API key should be sent for authentication when using
`AuthType.USE_GEMINI` or `AuthType.USE_VERTEX_AI`.
- Valid values are `x-goog-api-key` (default) or `bearer`.
- If set to `bearer`, the API key will be sent in the
`Authorization: Bearer <key>` header.
- Example: `export GEMINI_API_KEY_AUTH_MECHANISM="bearer"`
- **`GOOGLE_API_KEY`**:
- Your Google Cloud API key.
- Required for using Vertex AI in express mode.
@@ -494,6 +506,9 @@ the `excludedProjectEnvVars` setting in your `settings.json` file.
- **`GEMINI_SANDBOX`**:
- Alternative to the `sandbox` setting in `settings.json`.
- Accepts `true`, `false`, `docker`, `podman`, or a custom command string.
- **`HTTP_PROXY` / `HTTPS_PROXY`**:
- Specifies the proxy server to use for outgoing HTTP/HTTPS requests.
- Example: `export HTTPS_PROXY="http://proxy.example.com:8080"`
- **`SEATBELT_PROFILE`** (macOS specific):
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
- `permissive-open`: (Default) Restricts writes to the project folder (and a
@@ -574,8 +589,6 @@ for that specific session.
- **`--telemetry-log-prompts`**:
- Enables logging of prompts for telemetry. See [telemetry](./telemetry.md)
for more information.
- **`--checkpointing`**:
- Enables [checkpointing](./checkpointing.md).
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all
available extensions are used.
@@ -583,9 +596,6 @@ for that specific session.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--proxy`**:
- Sets the proxy for the CLI.
- Example: `--proxy http://localhost:7890`.
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory
support.
+2 -3
View File
@@ -363,9 +363,8 @@ containerized environment.
}
```
You can also specify a custom, hardened Docker image for the sandbox using the
`--sandbox-image` command-line argument or by building a custom
`sandbox.Dockerfile` as described in the
You can also specify a custom, hardened Docker image for the sandbox by building
a custom `sandbox.Dockerfile` as described in the
[Sandboxing documentation](./sandbox.md).
## Controlling Network Access via Proxy
+2 -2
View File
@@ -13,8 +13,8 @@ Git).
When you add a path to your `.geminiignore` file, tools that respect this file
will exclude matching files and directories from their operations. For example,
when you use the [`read_many_files`](../tools/multi-file.md) command, any paths
in your `.geminiignore` file will be automatically excluded.
when you use the `@` command to share files, any paths in your `.geminiignore`
file will be automatically excluded.
For the most part, `.geminiignore` follows the conventions of `.gitignore`
files:
+4 -1
View File
@@ -7,11 +7,14 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
## Basic features
- **[Commands](./commands.md):** A reference for all built-in slash commands
(e.g., `/help`, `/chat`, `/tools`).
- **[Custom Commands](./custom-commands.md):** Create your own commands and
shortcuts for frequently used prompts.
- **[Headless Mode](./headless.md):** Use Gemini CLI programmatically for
scripting and automation.
- **[Model Selection](./model.md):** Configure the Gemini AI model used by the
CLI.
- **[Settings](./settings.md):** Configure various aspects of the CLI's behavior
and appearance.
- **[Themes](./themes.md):** Customizing the CLI's appearance with different
themes.
- **[Keyboard Shortcuts](./keyboard-shortcuts.md):** A reference for all
+128 -69
View File
@@ -1,84 +1,143 @@
# Gemini CLI Keyboard Shortcuts
This document lists the available keyboard shortcuts within Gemini CLI.
Gemini CLI ships with a set of default keyboard shortcuts for editing input,
navigating history, and controlling the UI. Use this reference to learn the
available combinations.
## General
<!-- KEYBINDINGS-AUTOGEN:START -->
| Shortcut | Description |
| ----------- | --------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+S` | Toggle copy mode (alternate buffer mode only). |
| `Ctrl+T` | Toggle the display of the todo list. |
| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. |
| `Shift+Tab` | Toggle auto-accepting edits approval mode. |
| `Option+M` | Toggle Markdown rendering for messages (raw markdown mode). |
| `F12` | Toggle the display of the debug console. |
#### Basic Controls
## Input Prompt
| Action | Keys |
| -------------------------------------------- | ------- |
| Confirm the current selection or choice. | `Enter` |
| Dismiss dialogs or cancel the current focus. | `Esc` |
| Shortcut | Description |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `!` | Toggle shell mode when the input is empty. |
| `\` (at end of line) + `Enter` | Insert a newline. |
| `Down Arrow` | Navigate down through the input history. |
| `Enter` | Submit the current prompt. |
| `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. |
| `Tab` | Autocomplete the current suggestion if one exists. |
| `Up Arrow` | Navigate up through the input history. |
| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. |
| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. |
| `Ctrl+C` | Clear the input prompt |
| `Esc` (double press) | Clear the input prompt. |
| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. `Ctrl+F` also toggles focus between input and interactive shell if active. |
| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
| `Ctrl+K` | Delete from the cursor to the end of the line. |
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
| `Ctrl+N` | Navigate down through the input history. |
| `Ctrl+P` | Navigate up through the input history. |
| `Ctrl+R` | Activate reverse command search history. |
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. |
| `Ctrl+Z` | Undo last text edit. |
| `Ctrl+Shift+Z` | Redo last undone text edit. |
#### Cursor Movement
## Suggestions
| Action | Keys |
| ----------------------------------------- | ---------------------- |
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End` |
| Shortcut | Description |
| ----------------------- | -------------------------------------- |
| `Down Arrow` / `Ctrl+N` | Navigate down through the suggestions. |
| `Tab` / `Enter` | Accept the selected suggestion. |
| `Up Arrow` / `Ctrl+P` | Navigate up through the suggestions. |
#### Editing
## Radio Button Select
| Action | Keys |
| ------------------------------------------------ | ----------------------------------------- |
| Delete from the cursor to the end of the line. | `Ctrl + K` |
| Delete from the cursor to the start of the line. | `Ctrl + U` |
| Clear all text in the input field. | `Ctrl + C` |
| Delete the previous word. | `Ctrl + Backspace`<br />`Cmd + Backspace` |
| Shortcut | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------- |
| `Down Arrow` / `j` | Move selection down. |
| `Enter` | Confirm selection. |
| `Up Arrow` / `k` | Move selection up. |
| `1-9` | Select an item by its number. |
| (multi-digit) | For items with numbers greater than 9, press the digits in quick succession to select the corresponding item. |
#### Screen Control
## IDE Integration
| Action | Keys |
| -------------------------------------------- | ---------- |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
| Shortcut | Description |
| -------- | --------------------------------- |
| `Ctrl+G` | See context CLI received from IDE |
#### Scrolling
## Meta+key combos on mac
| Action | Keys |
| ------------------------ | -------------------- |
| Scroll content up. | `Shift + Up Arrow` |
| Scroll content down. | `Shift + Down Arrow` |
| Scroll to the top. | `Home` |
| Scroll to the bottom. | `End` |
| Scroll up by one page. | `Page Up` |
| Scroll down by one page. | `Page Down` |
On Mac, all Meta+char combos should work normally except for these three which
are mapped to special functionality.
#### History & Search
- `meta+b`: "∫" back one word
- `meta+f`: "ƒ" forward one word
- `meta+m`: "µ" toggle markup view
| Action | Keys |
| -------------------------------------------- | --------------------- |
| Show the previous entry in history. | `Ctrl + P (no Shift)` |
| Show the next entry in history. | `Ctrl + N (no Shift)` |
| Start reverse search through history. | `Ctrl + R` |
| Insert the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
#### Navigation
| Action | Keys |
| -------------------------------- | ------------------------------------------- |
| Move selection up in lists. | `Up Arrow (no Shift)` |
| Move selection down in lists. | `Down Arrow (no Shift)` |
| Move up within dialog options. | `Up Arrow (no Shift)`<br />`K (no Shift)` |
| Move down within dialog options. | `Down Arrow (no Shift)`<br />`J (no Shift)` |
#### Suggestions & Completions
| Action | Keys |
| --------------------------------------- | -------------------------------------------------- |
| Accept the inline suggestion. | `Tab`<br />`Enter (no Ctrl)` |
| Move to the previous completion option. | `Up Arrow (no Shift)`<br />`Ctrl + P (no Shift)` |
| Move to the next completion option. | `Down Arrow (no Shift)`<br />`Ctrl + N (no Shift)` |
| Expand an inline suggestion. | `Right Arrow` |
| Collapse an inline suggestion. | `Left Arrow` |
#### Text Input
| Action | Keys |
| ------------------------------------ | ------------------------------------------------------------------------------------------- |
| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd, not Paste)` |
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Paste + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
#### External Tools
| Action | Keys |
| ---------------------------------------------- | ---------- |
| Open the current prompt in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V` |
#### App Controls
| Action | Keys |
| ----------------------------------------------------------------- | ---------- |
| Toggle detailed error information. | `F12` |
| Toggle the full TODO list. | `Ctrl + T` |
| Toggle IDE context details. | `Ctrl + G` |
| Toggle Markdown rendering. | `Cmd + M` |
| Toggle copy mode when the terminal is using the alternate buffer. | `Ctrl + S` |
| Expand a height-constrained response to show additional lines. | `Ctrl + S` |
| Toggle focus between the shell and Gemini input. | `Ctrl + F` |
#### Session Control
| Action | Keys |
| -------------------------------------------- | ---------- |
| Cancel the current request or quit the CLI. | `Ctrl + C` |
| Exit the CLI when the input buffer is empty. | `Ctrl + D` |
<!-- KEYBINDINGS-AUTOGEN:END -->
## Additional Context-Specific Shortcuts
- `Ctrl+Y`: Toggle YOLO (auto-approval) mode for tool calls.
- `Shift+Tab`: Toggle Auto Edit (auto-accept edits) mode.
- `Option+M` (macOS): Entering `µ` with Option+M also toggles Markdown
rendering, matching `Cmd+M`.
- `!` on an empty prompt: Enter or exit shell mode.
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
mode.
- `Ctrl+Delete` / `Meta+Delete`: Delete the word to the right of the cursor.
- `Ctrl+B` or `Left Arrow`: Move the cursor one character to the left while
editing text.
- `Ctrl+F` or `Right Arrow`: Move the cursor one character to the right; with an
embedded shell attached, `Ctrl+F` still toggles focus.
- `Ctrl+D` or `Delete`: Remove the character immediately to the right of the
cursor.
- `Ctrl+H` or `Backspace`: Remove the character immediately to the left of the
cursor.
- `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B`: Move one word to the left.
- `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F`: Move one word to the
right.
- `Ctrl+W`: Delete the word to the left of the cursor (in addition to
`Ctrl+Backspace` / `Cmd+Backspace`).
- `Ctrl+Z` / `Ctrl+Shift+Z`: Undo or redo the most recent text edit.
- `Meta+Enter`: Open the current input in an external editor (alias for
`Ctrl+X`).
- `Esc` pressed twice quickly: Clear the current input buffer.
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
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.
+41
View File
@@ -0,0 +1,41 @@
## Model Routing
Gemini CLI includes a model routing feature that automatically switches to a
fallback model in case of a model failure. This feature is enabled by default
and provides resilience when the primary model is unavailable.
## How it Works
Model routing is not based on prompt complexity, but is a fallback mechanism.
Here's how it works:
1. **Model Failure:** If the currently selected model fails to respond (for
example, due to a server error or other issue), the CLI will initiate the
fallback process.
2. **User Consent:** The CLI will prompt you to ask if you want to switch to
the fallback model. This is handled by the `fallbackModelHandler`.
3. **Fallback Activation:** If you consent, the CLI will activate the fallback
mode by calling `config.setFallbackMode(true)`.
4. **Model Switch:** On the next request, the CLI will use the
`DEFAULT_GEMINI_FLASH_MODEL` as the fallback model. This is handled by the
`resolveModel` function in
`packages/cli/src/zed-integration/zedIntegration.ts` which checks if
`isInFallbackMode()` is true.
### Model Selection Precedence
The model used by Gemini CLI is determined by the following order of precedence:
1. **`--model` command-line flag:** A model specified with the `--model` flag
when launching the CLI will always be used.
2. **`GEMINI_MODEL` environment variable:** If the `--model` flag is not used,
the CLI will use the model specified in the `GEMINI_MODEL` environment
variable.
3. **`model.name` in `settings.json`:** If neither of the above are set, the
model specified in the `model.name` property of your `settings.json` file
will be used.
4. **Default Model:** If none of the above are set, the default model will be
used. The default model is `auto`
+22 -8
View File
@@ -14,14 +14,28 @@ Use the following command in Gemini CLI:
Running this command will open a dialog with your model options:
- **Auto (recommended):** Let the system choose the best model for your task.
Typically, this is the best option.
- **Pro:** For complex tasks that require deep reasoning and creativity. The Pro
model may take longer to return a response.
- **Flash:** For tasks that need a balance of speed and reasoning. The Flash
model will usually return a faster response than Pro.
- **Flash-Lite:** For simple tasks that need to be done quickly. The Flash-Lite
model is typically the fastest.
| Option | Description | Models |
| ------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Auto (recommended) | Let the system choose the best model for your task. | gemini-3-pro-preview (if enabled), gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite |
| Pro | For complex tasks that require deep reasoning and creativity. | gemini-3-pro-preview (if enabled), gemini-2.5-pro |
| Flash | For tasks that need a balance of speed and reasoning. | gemini-2.5-flash |
| Flash-Lite | For simple tasks that need to be done quickly. | gemini-2.5-flash-lite |
### Gemini 3 Pro and Preview Features
Note: Gemini 3 is not currently available on all account types. To learn more
about Gemini 3 access, refer to
[Gemini 3 Pro on Gemini CLI](../get-started/gemini-3.md).
To enable Gemini 3 Pro (if available), enable
[**Preview features** by using the `settings` command](../cli/settings.md). Once
enabled, Gemini CLI will attempt to use Gemini 3 Pro when you select **Auto** or
**Pro**. Both **Auto** and **Pro** will try to use Gemini 3 Pro before falling
back to Gemini 2.5 Pro.
You can also use the `--model` flag to specify a particular Gemini model on
startup. For more details, refer to the
[configuration documentation](./configuration.md).
Changes to these settings will be applied to all subsequent interactions with
Gemini CLI.
+158
View File
@@ -0,0 +1,158 @@
# Session Management
Gemini CLI includes robust session management features that automatically save
your conversation history. This allows you to interrupt your work and resume
exactly where you left off, review past interactions, and manage your
conversation history effectively.
## Automatic Saving
Every time you interact with Gemini CLI, your session is automatically saved.
This happens in the background without any manual intervention.
- **What is saved:** The complete conversation history, including:
- Your prompts and the model's responses.
- All tool executions (inputs and outputs).
- Token usage statistics (input/output/cached, etc.).
- Assistant thoughts/reasoning summaries (when available).
- **Location:** Sessions are stored in `~/.gemini/tmp/<project_hash>/chats/`.
- **Scope:** Sessions are project-specific. Switching directories to a different
project will switch to that project's session history.
## Resuming Sessions
You can resume a previous session to continue the conversation with all prior
context restored.
### From the Command Line
When starting the CLI, you can use the `--resume` (or `-r`) flag:
- **Resume latest:**
```bash
gemini --resume
```
This immediately loads the most recent session.
- **Resume by index:** First, list available sessions (see
[Listing Sessions](#listing-sessions)), then use the index number:
```bash
gemini --resume 1
```
- **Resume by ID:** You can also provide the full session UUID:
```bash
gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890
```
### From the Interactive Interface
While the CLI is running, you can use the `/resume` slash command to open the
**Session Browser**:
```text
/resume
```
This opens an interactive interface where you can:
- **Browse:** Scroll through a list of your past sessions.
- **Preview:** See details like the session date, message count, and the first
user prompt.
- **Search:** Press `/` to enter search mode, then type to filter sessions by ID
or content.
- **Select:** Press `Enter` to resume the selected session.
## Managing Sessions
### Listing Sessions
To see a list of all available sessions for the current project from the command
line:
```bash
gemini --list-sessions
```
Output example:
```text
Available sessions for this project (3):
1. Fix bug in auth (2 days ago) [a1b2c3d4]
2. Refactor database schema (5 hours ago) [e5f67890]
3. Update documentation (Just now) [abcd1234]
```
### Deleting Sessions
You can remove old or unwanted sessions to free up space or declutter your
history.
**From the Command Line:** Use the `--delete-session` flag with an index or ID:
```bash
gemini --delete-session 2
```
**From the Session Browser:**
1. Open the browser with `/resume`.
2. Navigate to the session you want to remove.
3. Press `x`.
## Configuration
You can configure how Gemini CLI manages your session history in your
`settings.json` file.
### Session Retention
To prevent your history from growing indefinitely, you can enable automatic
cleanup policies.
```json
{
"general": {
"sessionRetention": {
"enabled": true,
"maxAge": "30d", // Keep sessions for 30 days
"maxCount": 50 // Keep the 50 most recent sessions
}
}
}
```
- **`enabled`**: (boolean) Master switch for session cleanup. Default is
`false`.
- **`maxAge`**: (string) Duration to keep sessions (e.g., "24h", "7d", "4w").
Sessions older than this will be deleted.
- **`maxCount`**: (number) Maximum number of sessions to retain. The oldest
sessions exceeding this count will be deleted.
- **`minRetention`**: (string) Minimum retention period (safety limit). Defaults
to `"1d"`; sessions newer than this period are never deleted by automatic
cleanup.
### Session Limits
You can also limit the length of individual sessions to prevent context windows
from becoming too large and expensive.
```json
{
"model": {
"maxSessionTurns": 100
}
}
```
- **`maxSessionTurns`**: (number) The maximum number of turns (user + model
exchanges) allowed in a single session. Set to `-1` for unlimited (default).
**Behavior when limit is reached:**
- **Interactive Mode:** The CLI shows an informational message and stops
sending requests to the model. You must manually start a new session.
- **Non-Interactive Mode:** The CLI exits with an error.
+112
View File
@@ -0,0 +1,112 @@
# Gemini CLI Settings (`/settings` Command)
Control your Gemini CLI experience with the `/settings` command. The `/settings`
command opens a dialog to view and edit all your Gemini CLI settings, including
your UI experience, keybindings, and accessibility features.
Your Gemini CLI settings are stored in a `settings.json` file. In addition to
using the `/settings` command, you can also edit them in one of the following
locations:
- **User settings**: `~/.gemini/settings.json`
- **Workspace settings**: `your-project/.gemini/settings.json`
Note: Workspace settings override user settings.
## Settings reference
Here is a list of all the available settings, grouped by category and ordered as
they appear in the UI.
### General
| UI Label | Setting | Description | Default |
| ------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- | ----------- |
| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` |
| Vim Mode | `general.vimMode` | Enable Vim keybindings. | `false` |
| Disable Auto Update | `general.disableAutoUpdate` | Disable automatic updates. | `false` |
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Session Retention | `general.sessionRetention` | Settings for automatic session cleanup. This feature is disabled by default. | `undefined` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup. | `false` |
### Output
| UI Label | Setting | Description | Default |
| ------------- | --------------- | ------------------------------------------------------ | ------- |
| Output Format | `output.format` | The format of the CLI output. Can be `text` or `json`. | `text` |
### UI
| UI Label | Setting | Description | Default |
| ------------------------------ | ---------------------------------------- | -------------------------------------------------------------------- | ------- |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar. | `false` |
| Show Status in Title | `ui.showStatusInTitle` | Show Gemini CLI status and thoughts in the terminal window title. | `false` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI. | `false` |
| Hide Banner | `ui.hideBanner` | Hide the application banner. | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI. | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI. | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `false` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `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. | `true` |
| Disable Loading Phrases | `ui.accessibility.disableLoadingPhrases` | Disable loading phrases for accessibility. | `false` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible. | `false` |
### IDE
| UI Label | Setting | Description | Default |
| -------- | ------------- | ---------------------------- | ------- |
| IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` |
### Model
| UI Label | Setting | Description | Default |
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.2` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
### Context
| UI Label | Setting | Description | Default |
| ------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` |
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
| Disable Fuzzy Search | `context.fileFiltering.disableFuzzySearch` | Disable fuzzy search when searching for files. | `false` |
### 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. | `10000` |
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `100` |
| Enable Message Bus Integration | `tools.enableMessageBusIntegration` | Enable policy-based tool confirmation via message bus integration. | `false` |
### Security
| UI Label | Setting | Description | Default |
| -------------------------- | ------------------------------ | -------------------------------------------------- | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
### Experimental
| UI Label | Setting | Description | Default |
| ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | ------- |
| 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` |
+1 -1
View File
@@ -8,7 +8,7 @@ This page contains tutorials for interacting with Gemini CLI.
> and understand the tools it provides. Your use of third-party servers is at
> your own risk.
This tutorial demonstrates how to set up a MCP server, using the
This tutorial demonstrates how to set up an MCP server, using the
[GitHub MCP server](https://github.com/github/github-mcp-server) as an example.
The GitHub MCP server provides tools for interacting with GitHub repositories,
such as creating issues and commenting on pull requests.
+3 -2
View File
@@ -54,10 +54,11 @@ gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release]
### Uninstalling an extension
To uninstall, run `gemini extensions uninstall <name>`:
To uninstall one or more extensions, run
`gemini extensions uninstall <name...>`:
```
gemini extensions uninstall gemini-cli-security
gemini extensions uninstall gemini-cli-security gemini-cli-another-extension
```
### Disabling an extension
+12 -2
View File
@@ -8,13 +8,23 @@ CLI, configure **one** of the following authentication methods:
- Use Gemini API key
- Use Vertex AI
- Headless (non-interactive) mode
- Google Cloud Shell
- Google Cloud Environments (Cloud Shell, Compute Engine, etc.)
## Quick Check: Running in Google Cloud Shell?
If you are running the Gemini CLI within a Google Cloud Shell environment,
authentication is typically automatic using your Cloud Shell credentials.
### Other Google Cloud Environments (e.g., Compute Engine)
Some other Google Cloud environments, such as Compute Engine VMs, might also
support automatic authentication. In these environments, Gemini CLI can
automatically use Application Default Credentials (ADC) sourced from the
environment's metadata server.
If automatic authentication does not occur in your environment, you will need to
use one of the interactive methods described below.
## Authenticate in Interactive mode
When you run Gemini CLI through the command-line, Gemini CLI will provide the
@@ -246,7 +256,7 @@ To avoid setting environment variables in every terminal session, you can:
## Non-interactive mode / headless environments
Non-interative mode / headless environments will use your existing
Non-interactive mode / headless environments will use your existing
authentication method, if an existing authentication credential is cached.
If you have not already logged in with an authentication credential (such as a
+3 -5
View File
@@ -611,6 +611,9 @@ the `excludedProjectEnvVars` setting in your `settings.json` file.
- **`GEMINI_SANDBOX`**:
- Alternative to the `sandbox` setting in `settings.json`.
- Accepts `true`, `false`, `docker`, `podman`, or a custom command string.
- **`HTTP_PROXY` / `HTTPS_PROXY`**:
- Specifies the proxy server to use for outgoing HTTP/HTTPS requests.
- Example: `export HTTPS_PROXY="http://proxy.example.com:8080"`
- **`SEATBELT_PROFILE`** (macOS specific):
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
- `permissive-open`: (Default) Restricts writes to the project folder (and a
@@ -692,8 +695,6 @@ for that specific session.
- **`--telemetry-log-prompts`**:
- Enables logging of prompts for telemetry. See
[telemetry](../cli/telemetry.md) for more information.
- **`--checkpointing`**:
- Enables [checkpointing](../cli/checkpointing.md).
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all
available extensions are used.
@@ -701,9 +702,6 @@ for that specific session.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--proxy`**:
- Sets the proxy for the CLI.
- Example: `--proxy http://localhost:7890`.
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory
support.
+257 -11
View File
@@ -98,6 +98,10 @@ their corresponding top-level category object in your `settings.json` file.
#### `general`
- **`general.previewFeatures`** (boolean):
- **Description:** Enable preview features (e.g., preview models).
- **Default:** `false`
- **`general.preferredEditor`** (string):
- **Description:** The preferred editor to open files in.
- **Default:** `undefined`
@@ -219,12 +223,16 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.showLineNumbers`** (boolean):
- **Description:** Show line numbers in the chat.
- **Default:** `false`
- **Default:** `true`
- **`ui.showCitations`** (boolean):
- **Description:** Show citations for generated text in the chat.
- **Default:** `false`
- **`ui.showModelInfoInChat`** (boolean):
- **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`
@@ -235,6 +243,13 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.incrementalRendering`** (boolean):
- **Description:** Enable incremental rendering for the UI. This option will
reduce flickering but may cause rendering artifacts. Only supported when
useAlternateBuffer is enabled.
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.customWittyPhrases`** (array):
- **Description:** Custom witty phrases to display during loading. When
provided, the CLI cycles through these instead of the defaults.
@@ -289,7 +304,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`model.compressionThreshold`** (number):
- **Description:** The fraction of context usage at which to trigger context
compression (e.g. 0.2, 0.3).
- **Default:** `0.2`
- **Default:** `0.5`
- **Requires restart:** Yes
- **`model.skipNextSpeakerCheck`** (boolean):
@@ -302,7 +317,212 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Named presets for model configs. Can be used in place of a
model name and can inherit from other aliases using an `extends` property.
- **Default:**
`{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingBudget":-1}}}},"gemini-2.5-pro":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-pro"}},"gemini-2.5-flash":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash"}},"gemini-2.5-flash-lite":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash-lite"}},"gemini-2.5-flash-base":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash"}},"classifier":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":1024,"thinkingConfig":{"thinkingBudget":512}}}},"prompt-completion":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"temperature":0.3,"maxOutputTokens":16000,"thinkingConfig":{"thinkingBudget":0}}}},"edit-corrector":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"thinkingConfig":{"thinkingBudget":0}}}},"summarizer-default":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"summarizer-shell":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"web-search":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"googleSearch":{}}]}}},"web-fetch":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"urlContext":{}}]}}},"web-fetch-fallback":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"llm-edit-fixer":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"next-speaker-checker":{"extends":"gemini-2.5-flash-base","modelConfig":{}}}`
```json
{
"base": {
"modelConfig": {
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
}
},
"chat-base": {
"extends": "base",
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"includeThoughts": true
},
"temperature": 1,
"topP": 0.95,
"topK": 64
}
}
},
"chat-base-2.5": {
"extends": "chat-base",
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"thinkingBudget": 8192
}
}
}
},
"chat-base-3": {
"extends": "chat-base",
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"thinkingLevel": "HIGH"
}
}
}
},
"gemini-3-pro-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3-pro-preview"
}
},
"gemini-2.5-pro": {
"extends": "chat-base-2.5",
"modelConfig": {
"model": "gemini-2.5-pro"
}
},
"gemini-2.5-flash": {
"extends": "chat-base-2.5",
"modelConfig": {
"model": "gemini-2.5-flash"
}
},
"gemini-2.5-flash-lite": {
"extends": "chat-base-2.5",
"modelConfig": {
"model": "gemini-2.5-flash-lite"
}
},
"gemini-2.5-flash-base": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash"
}
},
"classifier": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 1024,
"thinkingConfig": {
"thinkingBudget": 512
}
}
}
},
"prompt-completion": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0.3,
"maxOutputTokens": 16000,
"thinkingConfig": {
"thinkingBudget": 0
}
}
}
},
"edit-corrector": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"thinkingConfig": {
"thinkingBudget": 0
}
}
}
},
"summarizer-default": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
}
},
"summarizer-shell": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
}
},
"web-search": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {
"generateContentConfig": {
"tools": [
{
"googleSearch": {}
}
]
}
}
},
"web-fetch": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {
"generateContentConfig": {
"tools": [
{
"urlContext": {}
}
]
}
}
},
"web-fetch-fallback": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {}
},
"loop-detection": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {}
},
"loop-detection-double-check": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-pro"
}
},
"llm-edit-fixer": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {}
},
"next-speaker-checker": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {}
},
"chat-compression-3-pro": {
"modelConfig": {
"model": "gemini-3-pro-preview"
}
},
"chat-compression-2.5-pro": {
"modelConfig": {
"model": "gemini-2.5-pro"
}
},
"chat-compression-2.5-flash": {
"modelConfig": {
"model": "gemini-2.5-flash"
}
},
"chat-compression-2.5-flash-lite": {
"modelConfig": {
"model": "gemini-2.5-flash-lite"
}
},
"chat-compression-default": {
"modelConfig": {
"model": "gemini-2.5-pro"
}
}
}
```
- **`modelConfigs.customAliases`** (object):
- **Description:** Custom named presets for model configs. These are merged
with (and override) the built-in aliases.
- **Default:** `{}`
- **`modelConfigs.overrides`** (array):
- **Description:** Apply specific configuration overrides based on matches,
@@ -480,8 +700,8 @@ their corresponding top-level category object in your `settings.json` file.
#### `useWriteTodos`
- **`useWriteTodos`** (boolean):
- **Description:** Enable the write_todos_list tool.
- **Default:** `false`
- **Description:** Enable the write_todos tool.
- **Default:** `true`
#### `security`
@@ -530,7 +750,11 @@ their corresponding top-level category object in your `settings.json` file.
- **`advanced.excludedEnvVars`** (array):
- **Description:** Environment variables to exclude from project context.
- **Default:** `["DEBUG","DEBUG_MODE"]`
- **Default:**
```json
["DEBUG", "DEBUG_MODE"]
```
- **`advanced.bugCommand`** (object):
- **Description:** Configuration for the bug report command.
@@ -548,10 +772,9 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.useModelRouter`** (boolean):
- **Description:** Enable model routing to route requests to the best model
based on complexity.
- **Default:** `true`
- **`experimental.isModelAvailabilityServiceEnabled`** (boolean):
- **Description:** Enable model routing using new availability service.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.enabled`** (boolean):
@@ -659,7 +882,12 @@ of v0.3.0:
{
"general": {
"vimMode": true,
"preferredEditor": "code"
"preferredEditor": "code",
"sessionRetention": {
"enabled": true,
"maxAge": "30d",
"maxCount": 100
}
},
"ui": {
"theme": "GitHub",
@@ -895,6 +1123,24 @@ for that specific session.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--resume [session_id]`** (**`-r [session_id]`**):
- Resume a previous chat session. Use "latest" for the most recent session,
provide a session index number, or provide a full session UUID.
- If no session_id is provided, defaults to "latest".
- Example: `gemini --resume 5` or `gemini --resume latest` or
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
- See [Session Management](../cli/session-management.md) for more details.
- **`--list-sessions`**:
- List all available chat sessions for the current project and exit.
- Shows session indices, dates, message counts, and preview of first user
message.
- Example: `gemini --list-sessions`
- **`--delete-session <identifier>`**:
- Delete a specific chat session by its index number or full session UUID.
- Use `--list-sessions` first to see available sessions, their indices, and
UUIDs.
- Example: `gemini --delete-session 3` or
`gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890`
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory
support.
+110
View File
@@ -0,0 +1,110 @@
# Gemini 3 Pro on Gemini CLI (Join the Waitlist)
Were excited to bring Gemini 3 Pro to Gemini CLI. For Google AI Ultra users
(Google AI Ultra for Business is not currently supported) and paid Gemini and
Vertex API key holders, Gemini 3 Pro is already available and ready to enable.
For everyone else, we're gradually expanding access
[through a waitlist](https://goo.gle/geminicli-waitlist-signup). Sign up for the
waitlist now to access Gemini 3 Pro once approved.
**Note:** Please wait until you have been approved to use Gemini 3 Pro to enable
**Preview Features**. If enabled early, the CLI will fallback to Gemini 2.5 Pro.
## Do I need to join the waitlist?
The following users will be **automatically granted access** to Gemini 3 Pro on
Gemini CLI:
- Google AI Ultra subscribers (excluding Google AI Ultra for Business, which is
on the roadmap).
- Gemini API key users
[with access to Gemini 3](https://ai.google.dev/gemini-api/docs/rate-limits).
- Vertex API key users
[with access to Gemini 3](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/quotas).
For **Gemini Code Assist Enterprise users**, access is coming soon.
Users not automatically granted access through one of these account types will
need to join the waitlist. This includes Google AI Pro, Gemini Code Assist
standard, and free tier users.
Note: Whether youre automatically granted access or accepted from the waitlist,
youll still need to enable Gemini 3 Pro
[using the `/settings` command](../cli/settings.md).
## How to join the waitlist
Users not automatically granted access will need to join the waitlist. Follow
these instructions to sign up:
- Install Gemini CLI.
- Authenticate using the **Login with Google** option. Youll see a banner that
says “Gemini 3 is now available.” If you do not see this banner, update your
installation of Gemini CLI to the most recent version.
- Fill out this Google form:
[Access Gemini 3 in Gemini CLI](https://goo.gle/geminicli-waitlist-signup).
Provide the email address of the account you used to authenticate with Gemini
CLI.
Users will be onboarded in batches, subject to availability. When youve been
granted access to Gemini 3 Pro, youll receive an acceptance email to your
submitted email address.
## How to use Gemini 3 Pro with Gemini CLI
Once you receive your acceptance emailor if you are automatically granted
accessyou still need to enable Gemini 3 Pro within Gemini CLI.
To enable Gemini 3 Pro, use the `/settings` command in Gemini CLI and set
**Preview Features** to `true`.
For more information, see [Gemini CLI Settings](../cli/settings.md).
### Usage limits and fallback
Gemini CLI will tell you when you reach your Gemini 3 Pro daily usage limit.
When you encounter that limit, youll be given the option to switch to Gemini
2.5 Pro, upgrade for higher limits, or stop. Youll also be told when your usage
limit resets and Gemini 3 Pro can be used again.
Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, youll see
a message prompting fallback to Gemini 2.5 Flash.
### Capacity errors
There may be times when the Gemini 3 Pro model is overloaded. When that happens,
Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro
or fallback to Gemini 2.5 Pro.
**Note:** The **Keep trying** option uses exponential backoff, in which Gemini
CLI waits longer between each retry, when the system is busy. If the retry
doesn't happen immediately, please wait a few minutes for the request to
process.
## Model selection & routing types
When using Gemini CLI, you may want to control how your requests are routed
between models. By default, Gemini CLI uses **Auto** routing.
When using Gemini 3 Pro, you may want to use Auto routing or Pro routing to
manage your usage limits:
- **Auto routing:** Auto routing first determines whether a prompt involves a
complex or simple operation. For simple prompts, it will automatically use
Gemini 2.5 Flash. For complex prompts, if Gemini 3 Pro is enabled, it will use
Gemini 3 Pro; otherwise, it will use Gemini 2.5 Pro.
- **Pro routing:** If you want to ensure your task is processed by the most
capable model, use `/model` and select **Pro**. Gemini CLI will prioritize the
most capable model available, including Gemini 3 Pro if it has been enabled.
To learn more about selecting a model and routing, refer to
[Gemini CLI Model Selection](../cli/model.md).
## Need help?
If you need help, we recommend searching for an existing
[GitHub issue](https://github.com/google-gemini/gemini-cli/issues). If you
cannot find a GitHub issue that matches your concern, you can
[create a new issue](https://github.com/google-gemini/gemini-cli/issues/new/choose).
For comments and feedback, consider opening a
[GitHub discussion](https://github.com/google-gemini/gemini-cli/discussions).
+1
View File
@@ -63,3 +63,4 @@ To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
- Find out more about [Gemini CLI's tools](../tools/index.md).
- Review [Gemini CLI's commands](../cli/commands.md).
- Learn how to [get started with Gemini 3](./gemini-3.md).
+3 -3
View File
@@ -4,8 +4,8 @@ Gemini CLI can integrate with your IDE to provide a more seamless and
context-aware experience. This integration allows the CLI to understand your
workspace better and enables powerful features like native in-editor diffing.
Currently, the only supported IDE is
[Visual Studio Code](https://code.visualstudio.com/) and other editors that
Currently, the supported IDEs are [Antigravity](https://antigravity.google),
[Visual Studio Code](https://code.visualstudio.com/), and other editors that
support VS Code extensions. To build support for other editors, see the
[IDE Companion Extension Spec](./ide-companion-spec.md).
@@ -191,7 +191,7 @@ messages and how to resolve them.
- **Cause:** You are running Gemini CLI in a terminal or environment that is
not a supported IDE.
- **Solution:** Run Gemini CLI from the integrated terminal of a supported
IDE, like VS Code.
IDE, like Antigravity or VS Code.
- **Message:**
`No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
+6 -2
View File
@@ -28,12 +28,18 @@ This documentation is organized into the following sections:
- **[Configuration](./get-started/configuration.md):** Information on
configuring the CLI.
- **[Examples](./get-started/examples.md):** Example usage of Gemini CLI.
- **[Get started with Gemini 3](./get-started/gemini-3.md):** Learn how to
enable and use Gemini 3.
### CLI
- **[CLI overview](./cli/index.md):** Overview of the command-line interface.
- **[Commands](./cli/commands.md):** Description of available CLI commands.
- **[Enterprise](./cli/enterprise.md):** Gemini CLI for enterprise.
- **[Model Selection](./cli/model.md):** Select the model used to process your
commands with `/model`.
- **[Settings](./cli/settings.md):** Configure various aspects of the CLI's
behavior and appearance with `/settings`.
- **[Themes](./cli/themes.md):** Themes for Gemini CLI.
- **[Token Caching](./cli/token-caching.md):** Token caching and optimization.
- **[Tutorials](./cli/tutorials.md):** Tutorials for Gemini CLI.
@@ -60,8 +66,6 @@ This documentation is organized into the following sections:
- **[File System Tools](./tools/file-system.md):** Documentation for the
`read_file` and `write_file` tools.
- **[MCP servers](./tools/mcp-server.md):** Using MCP servers with Gemini CLI.
- **[Multi-File Read Tool](./tools/multi-file.md):** Documentation for the
`read_many_files` tool.
- **[Shell Tool](./tools/shell.md):** Documentation for the `run_shell_command`
tool.
- **[Web Fetch Tool](./tools/web-fetch.md):** Documentation for the `web_fetch`
+12 -4
View File
@@ -23,6 +23,10 @@
"label": "Gemini CLI Quickstart",
"slug": "docs/get-started"
},
{
"label": "Gemini 3 Pro on Gemini CLI",
"slug": "docs/get-started/gemini-3"
},
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
@@ -80,6 +84,14 @@
"label": "Sandbox",
"slug": "docs/cli/sandbox"
},
{
"label": "Session Management",
"slug": "docs/cli/session-management"
},
{
"label": "Settings",
"slug": "docs/cli/settings"
},
{
"label": "Telemetry",
"slug": "docs/cli/telemetry"
@@ -138,10 +150,6 @@
"label": "File System",
"slug": "docs/tools/file-system"
},
{
"label": "Multi-File Read",
"slug": "docs/tools/multi-file"
},
{
"label": "Shell",
"slug": "docs/tools/shell"
-3
View File
@@ -82,9 +82,6 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
from URLs.
- **[Web Search Tool](./web-search.md) (`google_web_search`):** For searching
the web.
- **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** (Deprecated,
will be removed in v0.14.0) A specialized tool for reading content from
multiple files or directories.
- **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling
information across sessions.
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
-113
View File
@@ -1,113 +0,0 @@
# Multi File Read Tool (`read_many_files`)
> **Deprecated:** This tool is deprecated and will be removed in v0.14.0. Please
> use `read_file` instead. If you need to read multiple files, you can make
> multiple parallel calls to `read_file`.
This document describes the `read_many_files` tool for the Gemini CLI.
## Description
Use `read_many_files` to read content from multiple files specified by paths or
glob patterns. The behavior of this tool depends on the provided files:
- For text files, this tool concatenates their content into a single string.
- For image (e.g., PNG, JPEG), PDF, audio (MP3, WAV), and video (MP4, MOV)
files, it reads and returns them as base64-encoded data, provided they are
explicitly requested by name or extension.
`read_many_files` can be used to perform tasks such as getting an overview of a
codebase, finding where specific functionality is implemented, reviewing
documentation, or gathering context from multiple configuration files.
**Note:** `read_many_files` looks for files following the provided paths or glob
patterns. A directory path such as `"/docs"` will return an empty result; the
tool requires a pattern such as `"/docs/*"` or `"/docs/*.md"` to identify the
relevant files.
### Arguments
`read_many_files` takes the following arguments:
- `paths` (list[string], required): An array of glob patterns or paths relative
to the tool's target directory (e.g., `["src/**/*.ts"]`,
`["README.md", "docs/*", "assets/logo.png"]`).
- `exclude` (list[string], optional): Glob patterns for files/directories to
exclude (e.g., `["**/*.log", "temp/"]`). These are added to default excludes
if `useDefaultExcludes` is true.
- `include` (list[string], optional): Additional glob patterns to include. These
are merged with `paths` (e.g., `["*.test.ts"]` to specifically add test files
if they were broadly excluded, or `["images/*.jpg"]` to include specific image
types).
- `recursive` (boolean, optional): Whether to search recursively. This is
primarily controlled by `**` in glob patterns. Defaults to `true`.
- `useDefaultExcludes` (boolean, optional): Whether to apply a list of default
exclusion patterns (e.g., `node_modules`, `.git`, non image/pdf binary files).
Defaults to `true`.
- `respect_git_ignore` (boolean, optional): Whether to respect .gitignore
patterns when finding files. Defaults to true.
## How to use `read_many_files` with the Gemini CLI
`read_many_files` searches for files matching the provided `paths` and `include`
patterns, while respecting `exclude` patterns and default excludes (if enabled).
- For text files: it reads the content of each matched file (attempting to skip
binary files not explicitly requested as image/PDF) and concatenates it into a
single string, with a separator `--- {filePath} ---` between the content of
each file. Uses UTF-8 encoding by default.
- The tool inserts a `--- End of content ---` after the last file.
- For image and PDF files: if explicitly requested by name or extension (e.g.,
`paths: ["logo.png"]` or `include: ["*.pdf"]`), the tool reads the file and
returns its content as a base64 encoded string.
- The tool attempts to detect and skip other binary files (those not matching
common image/PDF types or not explicitly requested) by checking for null bytes
in their initial content.
Usage:
```
read_many_files(paths=["Your files or paths here."], include=["Additional files to include."], exclude=["Files to exclude."], recursive=False, useDefaultExcludes=false, respect_git_ignore=true)
```
## `read_many_files` examples
Read all TypeScript files in the `src` directory:
```
read_many_files(paths=["src/**/*.ts"])
```
Read the main README, all Markdown files in the `docs` directory, and a specific
logo image, excluding a specific file:
```
read_many_files(paths=["README.md", "docs/**/*.md", "assets/logo.png"], exclude=["docs/OLD_README.md"])
```
Read all JavaScript files but explicitly include test files and all JPEGs in an
`images` folder:
```
read_many_files(paths=["**/*.js"], include=["**/*.test.js", "images/**/*.jpg"], useDefaultExcludes=False)
```
## Important notes
- **Binary file handling:**
- **Image/PDF/Audio/Video files:** The tool can read common image types (PNG,
JPEG, etc.), PDF, audio (mp3, wav), and video (mp4, mov) files, returning
them as base64 encoded data. These files _must_ be explicitly targeted by
the `paths` or `include` patterns (e.g., by specifying the exact filename
like `video.mp4` or a pattern like `*.mov`).
- **Other binary files:** The tool attempts to detect and skip other types of
binary files by examining their initial content for null bytes. The tool
excludes these files from its output.
- **Performance:** Reading a very large number of files or very large individual
files can be resource-intensive.
- **Path specificity:** Ensure paths and glob patterns are correctly specified
relative to the tool's target directory. For image/PDF files, ensure the
patterns are specific enough to include them.
- **Default excludes:** Be aware of the default exclusion patterns (like
`node_modules`, `.git`) and use `useDefaultExcludes=False` if you need to
override them, but do so cautiously.
+4 -3
View File
@@ -6,7 +6,8 @@ This document describes the `write_todos` tool for the Gemini CLI.
The `write_todos` tool allows the Gemini agent to create and manage a list of
subtasks for complex user requests. This provides you, the user, with greater
visibility into the agent's plan and its current progress.
visibility into the agent's plan and its current progress. It also helps with
alignment where the agent is less likely to lose track of its current goal.
### Arguments
@@ -49,8 +50,8 @@ write_todos({
## Important notes
- **Enabling:** This tool is disabled by default. To use it, you must enable it
in your `settings.json` file by setting `"useWriteTodos": true`.
- **Enabling:** This tool is enabled by default. You can disable it in your
`settings.json` file by setting `"useWriteTodos": false`.
- **Intended Use:** This tool is primarily used by the agent for complex,
multi-turn tasks. It is generally not used for simple, single-turn questions.
-1
View File
@@ -29,7 +29,6 @@ export default tseslint.config(
// Global ignores
ignores: [
'node_modules/*',
'.integration-tests/**',
'eslint.config.js',
'packages/**/dist/**',
'bundle/**',
+5 -2
View File
@@ -84,8 +84,11 @@ describe('extension reloading', () => {
await run.expectText('- hello');
// Update the extension, expect the list to update, and mcp servers as well.
await run.sendText('/extensions update test-extension');
await run.type('\r');
await run.sendKeys('/extensions update test-extension');
await run.expectText('/extensions update test-extension');
await run.sendKeys('\r');
await new Promise((resolve) => setTimeout(resolve, 500));
await run.sendKeys('\r');
await run.expectText(
` * test-server (remote): http://localhost:${portB}/mcp`,
);
+1 -1
View File
@@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('read_many_files', () => {
it('should be able to read multiple files', async () => {
it.skip('should be able to read multiple files', async () => {
const rig = new TestRig();
await rig.setup('should be able to read multiple files');
rig.createFile('file1.txt', 'file 1 content');
+11 -2
View File
@@ -192,7 +192,12 @@ export class InteractiveRun {
timeout,
200,
);
expect(found, `Did not find expected text: "${text}"`).toBe(true);
expect(
found,
`Did not find expected text: "${text}". Output was:\n${stripAnsi(
this.output,
)}`,
).toBe(true);
}
// This types slowly to make sure command is correct, but only work for short
@@ -303,6 +308,7 @@ export class TestRig {
// Nightly releases sometimes becomes out of sync with local code and
// triggers auto-update, which causes tests to fail.
disableAutoUpdate: true,
previewFeatures: false,
},
telemetry: {
enabled: true,
@@ -315,6 +321,9 @@ export class TestRig {
selectedType: 'gemini-api-key',
},
},
ui: {
useAlternateBuffer: true,
},
model: DEFAULT_GEMINI_MODEL,
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
@@ -1004,7 +1013,7 @@ export class TestRig {
const options: pty.IPtyForkOptions = {
name: 'xterm-color',
cols: 80,
rows: 24,
rows: 80,
cwd: this.testDir!,
env: Object.fromEntries(
Object.entries(env).filter(([, v]) => v !== undefined),
+927 -272
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.19.0-preview.0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.0-nightly.20251111.51f952e7"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.19.0-preview.0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -30,6 +30,7 @@
"predocs:settings": "npm run build --workspace @google/gemini-cli-core",
"schema:settings": "tsx ./scripts/generate-settings-schema.ts",
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
"build": "node scripts/build.js",
"build-and-start": "npm run build && npm run start",
"build:vscode": "node scripts/build_vscode_companion.js",
@@ -61,7 +62,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.2",
"ink": "npm:@jrichman/ink@6.4.6",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -96,7 +97,7 @@
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^10.4.5",
"glob": "^12.0.0",
"globals": "^16.0.0",
"google-artifactregistry-auth": "^3.4.0",
"husky": "^9.1.7",
@@ -118,7 +119,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.2",
"ink": "npm:@jrichman/ink@6.4.6",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
@@ -153,7 +153,7 @@ syntax = "proto3";
import "google/protobuf/struct.proto";
// ToolCall is the central message represeting a tool's execution lifecycle.
// ToolCall is the central message representing a tool's execution lifecycle.
// The entire object is sent from the agent to client on every update.
message ToolCall {
// A unique identifier, assigned by the agent
@@ -197,7 +197,7 @@ enum ToolCallStatus {
CANCELLED = 5;
}
// ToolOuput represents the final, successful, output of a tool
// ToolOutput represents the final, successful, output of a tool
message ToolOutput {
oneof result {
string text = 1;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.19.0-preview.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+1 -2
View File
@@ -436,7 +436,6 @@ export class Task {
onToolCallsUpdate: this._schedulerToolCallsUpdate.bind(this),
getPreferredEditor: () => 'vscode',
config: this.config,
onEditorClose: () => {},
});
return scheduler;
}
@@ -461,7 +460,7 @@ export class Task {
): Message {
const messageParts: Part[] = [];
// Create a serializable version of the ToolCall (pick necesssary
// Create a serializable version of the ToolCall (pick necessary
// properties/avoid methods causing circular reference errors)
const serializableToolCall: Partial<ToolCall> = this._pickFields(
tc,
@@ -14,7 +14,9 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
HookSystem,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
import { expect, vi } from 'vitest';
@@ -54,8 +56,13 @@ export function createMockConfig(
getMessageBus: vi.fn(),
getPolicyEngine: vi.fn(),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getEnableHooks: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
mockConfig.getHookSystem = vi
.fn()
.mockReturnValue(new HookSystem(mockConfig));
mockConfig.getGeminiClient = vi
.fn()
+9 -7
View File
@@ -6,25 +6,27 @@
* SPDX-License-Identifier: Apache-2.0
*/
import './src/gemini.js';
import { main } from './src/gemini.js';
import { debugLogger, FatalError } from '@google/gemini-cli-core';
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Global Entry Point ---
main().catch((error) => {
main().catch(async (error) => {
await runExitCleanup();
if (error instanceof FatalError) {
let errorMessage = error.message;
if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
debugLogger.error(errorMessage);
writeToStderr(errorMessage + '\n');
process.exit(error.exitCode);
}
debugLogger.error('An unexpected critical error occurred:');
writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) {
debugLogger.error(error.stack);
writeToStderr(error.stack + '\n');
} else {
debugLogger.error(String(error));
writeToStderr(String(error) + '\n');
}
process.exit(1);
});
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.19.0-preview.0",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,24 +25,25 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.0-nightly.20251111.51f952e7"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.19.0-preview.0"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.16.0",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.15.1",
"@modelcontextprotocol/sdk": "^1.23.0",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"clipboardy": "^5.0.0",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"extract-zip": "^2.0.1",
"fzf": "^0.5.2",
"glob": "^10.4.5",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.2",
"ink": "npm:@jrichman/ink@6.4.6",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -1,5 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
"
`;
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
"
`;
exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
"
`;
exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
"Use mock tool
Use mock tool again
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { extensionsCommand } from './extensions.js';
// Mock subcommands
vi.mock('./extensions/install.js', () => ({
installCommand: { command: 'install' },
}));
vi.mock('./extensions/uninstall.js', () => ({
uninstallCommand: { command: 'uninstall' },
}));
vi.mock('./extensions/list.js', () => ({ listCommand: { command: 'list' } }));
vi.mock('./extensions/update.js', () => ({
updateCommand: { command: 'update' },
}));
vi.mock('./extensions/disable.js', () => ({
disableCommand: { command: 'disable' },
}));
vi.mock('./extensions/enable.js', () => ({
enableCommand: { command: 'enable' },
}));
vi.mock('./extensions/link.js', () => ({ linkCommand: { command: 'link' } }));
vi.mock('./extensions/new.js', () => ({ newCommand: { command: 'new' } }));
vi.mock('./extensions/validate.js', () => ({
validateCommand: { command: 'validate' },
}));
// Mock gemini.js
vi.mock('../gemini.js', () => ({
initializeOutputListenersAndFlush: vi.fn(),
}));
describe('extensionsCommand', () => {
it('should have correct command and aliases', () => {
expect(extensionsCommand.command).toBe('extensions <command>');
expect(extensionsCommand.aliases).toEqual(['extension']);
expect(extensionsCommand.describe).toBe('Manage Gemini CLI extensions.');
});
it('should register all subcommands in builder', () => {
const mockYargs = {
middleware: vi.fn().mockReturnThis(),
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
version: vi.fn().mockReturnThis(),
};
// @ts-expect-error - Mocking yargs
extensionsCommand.builder(mockYargs);
expect(mockYargs.middleware).toHaveBeenCalled();
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'install' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'uninstall' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'update' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'disable' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'enable' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'link' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'new' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'validate' });
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
expect(mockYargs.version).toHaveBeenCalledWith(false);
});
it('should have a handler that does nothing', () => {
// @ts-expect-error - Handler doesn't take arguments in this case
expect(extensionsCommand.handler()).toBeUndefined();
});
});
+2
View File
@@ -14,6 +14,7 @@ import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
import { validateCommand } from './extensions/validate.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -21,6 +22,7 @@ export const extensionsCommand: CommandModule = {
describe: 'Manage Gemini CLI extensions.',
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(installCommand)
.command(uninstallCommand)
.command(listCommand)
@@ -0,0 +1,248 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleDisable, disableCommand } from './disable.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
loadSettings,
SettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions disable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockGetErrorMessage = vi.mocked(getErrorMessage);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
mockExtensionManager.prototype.disableExtension = vi
.fn()
.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleDisable', () => {
it.each([
{
name: 'my-extension',
scope: undefined,
expectedScope: SettingScope.User,
expectedLog:
'Extension "my-extension" successfully disabled for scope "undefined".',
},
{
name: 'my-extension',
scope: 'user',
expectedScope: SettingScope.User,
expectedLog:
'Extension "my-extension" successfully disabled for scope "user".',
},
{
name: 'my-extension',
scope: 'workspace',
expectedScope: SettingScope.Workspace,
expectedLog:
'Extension "my-extension" successfully disabled for scope "workspace".',
},
])(
'should disable an extension in the $expectedScope scope when scope is $scope',
async ({ name, scope, expectedScope, expectedLog }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleDisable({ name, scope });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(
mockExtensionManager.prototype.loadExtensions,
).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.disableExtension,
).toHaveBeenCalledWith(name, expectedScope);
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
mockCwd.mockRestore();
},
);
it('should log an error message and exit with code 1 when extension disabling fails', async () => {
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Disable failed');
(
mockExtensionManager.prototype.disableExtension as Mock
).mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('Disable failed message');
await handleDisable({ name: 'my-extension' });
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Disable failed message',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
});
});
describe('disableCommand', () => {
const command = disableCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('disable [--scope] <name>');
expect(command.describe).toBe('Disables an extension.');
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
option: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
option: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional and option arguments', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
describe: 'The name of the extension to disable.',
type: 'string',
});
expect(yargsMock.option).toHaveBeenCalledWith('scope', {
describe: 'The scope to disable the extension in.',
type: 'string',
default: SettingScope.User,
});
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw for invalid scope', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`;
expect(() => checkCallback({ scope: 'invalid' })).toThrow(
expectedError,
);
});
it.each(['user', 'workspace', 'USER', 'WorkSpace'])(
'check function should return true for valid scope "%s"',
(scope) => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
expect(checkCallback({ scope })).toBe(true);
},
);
});
it('handler should trigger extension disabling', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
name: string;
scope: string;
[key: string]: unknown;
}
const argv: TestArgv = {
name: 'test-ext',
scope: 'workspace',
_: [],
$0: '',
};
await (command.handler as unknown as (args: TestArgv) => void)(argv);
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.disableExtension,
).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "test-ext" successfully disabled for scope "workspace".',
);
mockCwd.mockRestore();
});
});
});
@@ -11,6 +11,7 @@ import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface DisableArgs {
name: string;
@@ -76,10 +77,11 @@ export const disableCommand: CommandModule = {
}
return true;
}),
handler: (argv) => {
handleDisable({
handler: async (argv) => {
await handleDisable({
name: argv['name'] as string,
scope: argv['scope'] as string,
});
await exitCli();
},
};
@@ -0,0 +1,216 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleEnable, enableCommand } from './enable.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
loadSettings,
SettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { FatalConfigError } from '@google/gemini-cli-core';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
getErrorMessage: vi.fn((error: { message: string }) => error.message),
FatalConfigError: class extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalConfigError';
}
},
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../config/extensions/consent.js');
vi.mock('../../config/extensions/extensionSettings.js');
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions enable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
mockExtensionManager.prototype.enableExtension = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleEnable', () => {
it.each([
{
name: 'my-extension',
scope: undefined,
expectedScope: SettingScope.User,
expectedLog:
'Extension "my-extension" successfully enabled in all scopes.',
},
{
name: 'my-extension',
scope: 'workspace',
expectedScope: SettingScope.Workspace,
expectedLog:
'Extension "my-extension" successfully enabled for scope "workspace".',
},
])(
'should enable an extension in the $expectedScope scope when scope is $scope',
async ({ name, scope, expectedScope, expectedLog }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleEnable({ name, scope });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(
mockExtensionManager.prototype.loadExtensions,
).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.enableExtension,
).toHaveBeenCalledWith(name, expectedScope);
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
mockCwd.mockRestore();
},
);
it('should throw FatalConfigError when extension enabling fails', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const error = new Error('Enable failed');
(
mockExtensionManager.prototype.enableExtension as Mock
).mockImplementation(() => {
throw error;
});
const promise = handleEnable({ name: 'my-extension' });
await expect(promise).rejects.toThrow(FatalConfigError);
await expect(promise).rejects.toThrow('Enable failed');
mockCwd.mockRestore();
});
});
describe('enableCommand', () => {
const command = enableCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('enable [--scope] <name>');
expect(command.describe).toBe('Enables an extension.');
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
option: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
option: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional and option arguments', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
describe: 'The name of the extension to enable.',
type: 'string',
});
expect(yargsMock.option).toHaveBeenCalledWith('scope', {
describe:
'The scope to enable the extension in. If not set, will be enabled in all scopes.',
type: 'string',
});
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw for invalid scope', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`;
expect(() => checkCallback({ scope: 'invalid' })).toThrow(
expectedError,
);
});
});
it('handler should call handleEnable', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
name: string;
scope: string;
[key: string]: unknown;
}
const argv: TestArgv = {
name: 'test-ext',
scope: 'workspace',
_: [],
$0: '',
};
await (command.handler as unknown as (args: TestArgv) => void)(argv);
expect(
mockExtensionManager.prototype.enableExtension,
).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
mockCwd.mockRestore();
});
});
});
@@ -14,6 +14,7 @@ import {
getErrorMessage,
} from '@google/gemini-cli-core';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface EnableArgs {
name: string;
@@ -32,9 +33,9 @@ export async function handleEnable(args: EnableArgs) {
try {
if (args.scope?.toLowerCase() === 'workspace') {
extensionManager.enableExtension(args.name, SettingScope.Workspace);
await extensionManager.enableExtension(args.name, SettingScope.Workspace);
} else {
extensionManager.enableExtension(args.name, SettingScope.User);
await extensionManager.enableExtension(args.name, SettingScope.User);
}
if (args.scope) {
debugLogger.log(
@@ -81,10 +82,11 @@ export const enableCommand: CommandModule = {
}
return true;
}),
handler: (argv) => {
handleEnable({
handler: async (argv) => {
await handleEnable({
name: argv['name'] as string,
scope: argv['scope'] as string,
});
await exitCli();
},
};
@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
// Mock the MCP server and transport
const mockRegisterTool = vi.fn();
const mockRegisterPrompt = vi.fn();
const mockConnect = vi.fn();
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
McpServer: vi.fn().mockImplementation(() => ({
registerTool: mockRegisterTool,
registerPrompt: mockRegisterPrompt,
connect: mockConnect,
})),
}));
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: vi.fn(),
}));
describe('MCP Server Example', () => {
beforeEach(async () => {
// Dynamically import the server setup after mocks are in place
await import('./example.js');
});
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it('should create an McpServer with the correct name and version', () => {
expect(McpServer).toHaveBeenCalledWith({
name: 'prompt-server',
version: '1.0.0',
});
});
it('should register the "fetch_posts" tool', () => {
expect(mockRegisterTool).toHaveBeenCalledWith(
'fetch_posts',
{
description: 'Fetches a list of posts from a public API.',
inputSchema: z.object({}).shape,
},
expect.any(Function),
);
});
it('should register the "poem-writer" prompt', () => {
expect(mockRegisterPrompt).toHaveBeenCalledWith(
'poem-writer',
{
title: 'Poem Writer',
description: 'Write a nice haiku',
argsSchema: expect.any(Object),
},
expect.any(Function),
);
});
it('should connect the server to an StdioServerTransport', () => {
expect(StdioServerTransport).toHaveBeenCalled();
expect(mockConnect).toHaveBeenCalledWith(expect.any(StdioServerTransport));
});
describe('fetch_posts tool implementation', () => {
it('should fetch posts and return a formatted response', async () => {
const mockPosts = [
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' },
];
global.fetch = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue(mockPosts),
});
const toolFn = (mockRegisterTool as Mock).mock.calls[0][2];
const result = await toolFn();
expect(global.fetch).toHaveBeenCalledWith(
'https://jsonplaceholder.typicode.com/posts',
);
expect(result).toEqual({
content: [
{
type: 'text',
text: JSON.stringify({ posts: mockPosts }),
},
],
});
});
});
describe('poem-writer prompt implementation', () => {
it('should generate a prompt with a title', () => {
const promptFn = (mockRegisterPrompt as Mock).mock.calls[0][2];
const result = promptFn({ title: 'My Poem' });
expect(result).toEqual({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Write a haiku called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
},
},
],
});
});
it('should generate a prompt with a title and mood', () => {
const promptFn = (mockRegisterPrompt as Mock).mock.calls[0][2];
const result = promptFn({ title: 'My Poem', mood: 'sad' });
expect(result).toEqual({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'Write a haiku with the mood sad called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
},
},
],
});
});
});
});
@@ -12,7 +12,7 @@
"@types/node": "^20.11.25"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"@modelcontextprotocol/sdk": "^1.23.0",
"zod": "^3.22.4"
}
}
@@ -48,6 +48,10 @@ vi.mock('node:fs/promises', () => ({
},
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions install command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([]).command(installCommand).fail(false);
@@ -18,6 +18,7 @@ import {
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface InstallArgs {
source: string;
@@ -130,5 +131,6 @@ export const installCommand: CommandModule = {
allowPreRelease: argv['pre-release'] as boolean | undefined,
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
},
};
@@ -0,0 +1,186 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleLink, linkCommand } from './link.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions link command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockGetErrorMessage = vi.mocked(getErrorMessage);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
mockExtensionManager.prototype.installOrUpdateExtension = vi
.fn()
.mockResolvedValue({ name: 'my-linked-extension' });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleLink', () => {
it('should link an extension from a local path', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleLink({ path: '/local/path/to/extension' });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.installOrUpdateExtension,
).toHaveBeenCalledWith({
source: '/local/path/to/extension',
type: 'link',
});
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "my-linked-extension" linked successfully and enabled.',
);
mockCwd.mockRestore();
});
it('should log an error message and exit with code 1 when linking fails', async () => {
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Link failed');
(
mockExtensionManager.prototype.installOrUpdateExtension as Mock
).mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('Link failed message');
await handleLink({ path: '/local/path/to/extension' });
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Link failed message',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
});
});
describe('linkCommand', () => {
const command = linkCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('link <path>');
expect(command.describe).toBe(
'Links an extension from a local path. Updates made to the local path will always be reflected.',
);
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional argument', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('path', {
describe: 'The name of the extension to link.',
type: 'string',
});
expect(yargsMock.check).toHaveBeenCalled();
});
});
it('handler should call handleLink', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
path: string;
[key: string]: unknown;
}
const argv: TestArgv = {
path: '/local/path/to/extension',
_: [],
$0: '',
};
await (command.handler as unknown as (args: TestArgv) => void)(argv);
expect(
mockExtensionManager.prototype.installOrUpdateExtension,
).toHaveBeenCalledWith({
source: '/local/path/to/extension',
type: 'link',
});
mockCwd.mockRestore();
});
});
});
@@ -15,6 +15,7 @@ import { requestConsentNonInteractive } from '../../config/extensions/consent.js
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface InstallArgs {
path: string;
@@ -60,5 +61,6 @@ export const linkCommand: CommandModule = {
await handleLink({
path: argv['path'] as string,
});
await exitCli();
},
};
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { format } from 'node:util';
import { type CommandModule } from 'yargs';
import { handleList, listCommand } from './list.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions list command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockGetErrorMessage = vi.mocked(getErrorMessage);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleList', () => {
it('should log a message if no extensions are installed', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue([]);
await handleList();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'No extensions installed.',
);
mockCwd.mockRestore();
});
it('should list all installed extensions', 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);
mockExtensionManager.prototype.toOutputString = vi.fn(
(ext) => `${ext.name}@${ext.version}`,
);
await handleList();
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'ext1@1.0.0\n\next2@2.0.0',
);
mockCwd.mockRestore();
});
it('should log an error message and exit with code 1 when listing fails', async () => {
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('List failed');
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('List failed message');
await handleList();
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'List failed message',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
});
});
describe('listCommand', () => {
const command = listCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('list');
expect(command.describe).toBe('Lists installed extensions.');
});
it('handler should call handleList', async () => {
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue([]);
await (command.handler as () => Promise<void>)();
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
});
});
});
@@ -11,6 +11,7 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
export async function handleList() {
try {
@@ -45,5 +46,6 @@ export const listCommand: CommandModule = {
builder: (yargs) => yargs,
handler: async () => {
await handleList();
await exitCli();
},
};
@@ -11,6 +11,9 @@ import * as fsPromises from 'node:fs/promises';
import path from 'node:path';
vi.mock('node:fs/promises');
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
const mockedFs = vi.mocked(fsPromises);
@@ -9,6 +9,7 @@ import { join, dirname, basename } from 'node:path';
import type { CommandModule } from 'yargs';
import { fileURLToPath } from 'node:url';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
interface NewArgs {
path: string;
@@ -100,5 +101,6 @@ export const newCommand: CommandModule = {
path: args['path'] as string,
template: args['template'] as string | undefined,
});
await exitCli();
},
};
@@ -4,18 +4,296 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { uninstallCommand } from './uninstall.js';
import yargs from 'yargs';
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleUninstall, uninstallCommand } from './uninstall.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential
// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining).
// The hoisted mocks persist across vi.clearAllMocks() calls, which is necessary
// for testing partial failure scenarios in the multiple extension uninstall feature.
// Hoisted mocks - these survive vi.clearAllMocks()
const mockUninstallExtension = vi.hoisted(() => vi.fn());
const mockLoadExtensions = vi.hoisted(() => vi.fn());
// Mock dependencies with hoisted functions
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
ExtensionManager: vi.fn().mockImplementation(() => ({
uninstallExtension: mockUninstallExtension,
loadExtensions: mockLoadExtensions,
setRequestConsent: vi.fn(),
setRequestSetting: vi.fn(),
})),
};
});
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions uninstall command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([])
.command(uninstallCommand)
.fail(false)
.locale('en');
expect(() => validationParser.parse('uninstall')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
const mockLoadSettings = vi.mocked(loadSettings);
const mockGetErrorMessage = vi.mocked(getErrorMessage);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
});
afterEach(() => {
mockLoadExtensions.mockClear();
mockUninstallExtension.mockClear();
vi.clearAllMocks();
});
describe('handleUninstall', () => {
it('should uninstall a single extension', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
mockUninstallExtension.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleUninstall({ names: ['my-extension'] });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(mockLoadExtensions).toHaveBeenCalled();
expect(mockUninstallExtension).toHaveBeenCalledWith(
'my-extension',
false,
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "my-extension" successfully uninstalled.',
);
mockCwd.mockRestore();
});
it('should uninstall multiple extensions', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
mockUninstallExtension.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] });
expect(mockUninstallExtension).toHaveBeenCalledTimes(3);
expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false);
expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false);
expect(mockUninstallExtension).toHaveBeenCalledWith('ext3', false);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext1" successfully uninstalled.',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext2" successfully uninstalled.',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext3" successfully uninstalled.',
);
mockCwd.mockRestore();
});
it('should report errors for failed uninstalls but continue with others', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Extension not found');
// Chain sequential mock behaviors - this works with hoisted mocks
mockUninstallExtension
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(error)
.mockResolvedValueOnce(undefined);
mockGetErrorMessage.mockReturnValue('Extension not found');
await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] });
expect(mockUninstallExtension).toHaveBeenCalledTimes(3);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext1" successfully uninstalled.',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Failed to uninstall "ext2": Extension not found',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "ext3" successfully uninstalled.',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
mockCwd.mockRestore();
});
it('should exit with error code if all uninstalls fail', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Extension not found');
mockUninstallExtension.mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('Extension not found');
await handleUninstall({ names: ['ext1', 'ext2'] });
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Failed to uninstall "ext1": Extension not found',
);
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Failed to uninstall "ext2": Extension not found',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
mockCwd.mockRestore();
});
it('should log an error message and exit with code 1 when initialization fails', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Initialization failed');
mockLoadExtensions.mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('Initialization failed message');
await handleUninstall({ names: ['my-extension'] });
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Initialization failed message',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
mockCwd.mockRestore();
});
});
describe('uninstallCommand', () => {
const command = uninstallCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('uninstall <names..>');
expect(command.describe).toBe('Uninstalls one or more extensions.');
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional argument', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('names', {
describe:
'The name(s) or source path(s) of the extension(s) to uninstall.',
type: 'string',
array: true,
});
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw for missing names', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
expect(() => checkCallback({ names: [] })).toThrow(
'Please include at least one extension name to uninstall as a positional argument.',
);
});
});
it('handler should call handleUninstall', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
mockUninstallExtension.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
names: string[];
[key: string]: unknown;
}
const argv: TestArgv = { names: ['my-extension'], _: [], $0: '' };
await (command.handler as unknown as (args: TestArgv) => void)(argv);
expect(mockUninstallExtension).toHaveBeenCalledWith(
'my-extension',
false,
);
mockCwd.mockRestore();
});
});
});
@@ -11,9 +11,10 @@ import { requestConsentNonInteractive } from '../../config/extensions/consent.js
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface UninstallArgs {
name: string; // can be extension name or source URL.
names: string[]; // can be extension names or source URLs.
}
export async function handleUninstall(args: UninstallArgs) {
@@ -26,8 +27,23 @@ export async function handleUninstall(args: UninstallArgs) {
settings: loadSettings(workspaceDir).merged,
});
await extensionManager.loadExtensions();
await extensionManager.uninstallExtension(args.name, false);
debugLogger.log(`Extension "${args.name}" successfully uninstalled.`);
const errors: Array<{ name: string; error: string }> = [];
for (const name of [...new Set(args.names)]) {
try {
await extensionManager.uninstallExtension(name, false);
debugLogger.log(`Extension "${name}" successfully uninstalled.`);
} catch (error) {
errors.push({ name, error: getErrorMessage(error) });
}
}
if (errors.length > 0) {
for (const { name, error } of errors) {
debugLogger.error(`Failed to uninstall "${name}": ${error}`);
}
process.exit(1);
}
} catch (error) {
debugLogger.error(getErrorMessage(error));
process.exit(1);
@@ -35,25 +51,28 @@ export async function handleUninstall(args: UninstallArgs) {
}
export const uninstallCommand: CommandModule = {
command: 'uninstall <name>',
describe: 'Uninstalls an extension.',
command: 'uninstall <names..>',
describe: 'Uninstalls one or more extensions.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name or source path of the extension to uninstall.',
.positional('names', {
describe:
'The name(s) or source path(s) of the extension(s) to uninstall.',
type: 'string',
array: true,
})
.check((argv) => {
if (!argv.name) {
if (!argv.names || (argv.names as string[]).length === 0) {
throw new Error(
'Please include the name of the extension to uninstall as a positional argument.',
'Please include at least one extension name to uninstall as a positional argument.',
);
}
return true;
}),
handler: async (argv) => {
await handleUninstall({
name: argv['name'] as string,
names: argv['names'] as string[],
});
await exitCli();
},
};
@@ -0,0 +1,233 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type CommandModule, type Argv } from 'yargs';
import { handleUpdate, updateCommand } from './update.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import * as update from '../../config/extensions/update.js';
import * as github from '../../config/extensions/github.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/update.js');
vi.mock('../../config/extensions/github.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions update command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockExtensionManager = vi.mocked(ExtensionManager);
const mockUpdateExtension = vi.mocked(update.updateExtension);
const mockCheckForExtensionUpdate = vi.mocked(github.checkForExtensionUpdate);
const mockCheckForAllExtensionUpdates = vi.mocked(
update.checkForAllExtensionUpdates,
);
const mockUpdateAllUpdatableExtensions = vi.mocked(
update.updateAllUpdatableExtensions,
);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: { experimental: { extensionReloading: true } },
} as unknown as LoadedSettings);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleUpdate', () => {
it.each([
{
state: ExtensionUpdateState.UPDATE_AVAILABLE,
expectedLog:
'Extension "my-extension" successfully updated: 1.0.0 → 1.1.0.',
shouldCallUpdateExtension: true,
},
{
state: ExtensionUpdateState.UP_TO_DATE,
expectedLog: 'Extension "my-extension" is already up to date.',
shouldCallUpdateExtension: false,
},
])(
'should handle single extension update state: $state',
async ({ state, expectedLog, shouldCallUpdateExtension }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const extensions = [{ name: 'my-extension', installMetadata: {} }];
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(extensions);
mockCheckForExtensionUpdate.mockResolvedValue(state);
mockUpdateExtension.mockResolvedValue({
name: 'my-extension',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
await handleUpdate({ name: 'my-extension' });
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
if (shouldCallUpdateExtension) {
expect(mockUpdateExtension).toHaveBeenCalled();
} else {
expect(mockUpdateExtension).not.toHaveBeenCalled();
}
mockCwd.mockRestore();
},
);
it.each([
{
updatedExtensions: [
{ name: 'ext1', originalVersion: '1.0.0', updatedVersion: '1.1.0' },
{ name: 'ext2', originalVersion: '2.0.0', updatedVersion: '2.1.0' },
],
expectedLog:
'Extension "ext1" successfully updated: 1.0.0 → 1.1.0.\nExtension "ext2" successfully updated: 2.0.0 → 2.1.0.',
},
{
updatedExtensions: [],
expectedLog: 'No extensions to update.',
},
])(
'should handle updating all extensions: %s',
async ({ updatedExtensions, expectedLog }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue([]);
mockCheckForAllExtensionUpdates.mockResolvedValue(undefined);
mockUpdateAllUpdatableExtensions.mockResolvedValue(updatedExtensions);
await handleUpdate({ all: true });
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
mockCwd.mockRestore();
},
);
});
describe('updateCommand', () => {
const command = updateCommand as CommandModule;
it('should have correct command and describe', () => {
expect(command.command).toBe('update [<name>] [--all]');
expect(command.describe).toBe(
'Updates all extensions or a named extension to the latest version.',
);
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
option: Mock;
conflicts: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
option: vi.fn().mockReturnThis(),
conflicts: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure arguments', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith(
'name',
expect.any(Object),
);
expect(yargsMock.option).toHaveBeenCalledWith(
'all',
expect.any(Object),
);
expect(yargsMock.conflicts).toHaveBeenCalledWith('name', 'all');
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw an error if neither a name nor --all is provided', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
expect(() => checkCallback({ name: undefined, all: false })).toThrow(
'Either an extension name or --all must be provided',
);
});
});
it('handler should call handleUpdate', async () => {
const extensions = [{ name: 'my-extension', installMetadata: {} }];
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(extensions);
mockCheckForExtensionUpdate.mockResolvedValue(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockUpdateExtension.mockResolvedValue({
name: 'my-extension',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
await (command.handler as (args: object) => Promise<void>)({
name: 'my-extension',
});
expect(mockUpdateExtension).toHaveBeenCalled();
});
});
});
@@ -19,6 +19,7 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface UpdateArgs {
name?: string;
@@ -144,5 +145,6 @@ export const updateCommand: CommandModule = {
name: argv['name'] as string | undefined,
all: argv['all'] as boolean | undefined,
});
await exitCli();
},
};
@@ -13,6 +13,10 @@ import path from 'node:path';
import * as os from 'node:os';
import { debugLogger } from '@google/gemini-cli-core';
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions validate command', () => {
it('should fail if no path is provided', () => {
const validationParser = yargs([]).command(validateCommand).fail(false);
@@ -15,6 +15,7 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { loadSettings } from '../../config/settings.js';
import { exitCli } from '../utils.js';
interface ValidateArgs {
path: string;
@@ -101,5 +102,6 @@ export const validateCommand: CommandModule = {
await handleValidate({
path: args['path'] as string,
});
await exitCli();
},
};
+1
View File
@@ -56,6 +56,7 @@ describe('mcp command', () => {
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
version: vi.fn().mockReturnThis(),
middleware: vi.fn().mockReturnThis(),
};
(mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv);
+2
View File
@@ -9,12 +9,14 @@ 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 { initializeOutputListenersAndFlush } from '../gemini.js';
export const mcpCommand: CommandModule = {
command: 'mcp',
describe: 'Manage MCP servers',
builder: (yargs: Argv) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(addCommand)
.command(removeCommand)
.command(listCommand)
+13 -4
View File
@@ -4,10 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, type Mock } from 'vitest';
import { describe, it, expect, vi, type Mock, type MockInstance } from 'vitest';
import yargs, { type Argv } from 'yargs';
import { addCommand } from './add.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
@@ -38,6 +43,7 @@ describe('mcp add command', () => {
let parser: Argv;
let mockSetValue: Mock;
let mockConsoleError: Mock;
let debugLoggerErrorSpy: MockInstance;
beforeEach(() => {
vi.resetAllMocks();
@@ -45,6 +51,9 @@ describe('mcp add command', () => {
parser = yargsInstance;
mockSetValue = vi.fn();
mockConsoleError = vi.fn();
debugLoggerErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(mockConsoleError);
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: {} }),
@@ -232,7 +241,7 @@ describe('mcp add command', () => {
parser.parseAsync(`add ${serverName} ${command}`),
).rejects.toThrow('process.exit called');
expect(mockConsoleError).toHaveBeenCalledWith(
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
'Error: Please use --scope user to edit settings in the home directory.',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
@@ -250,7 +259,7 @@ describe('mcp add command', () => {
parser.parseAsync(`add --scope project ${serverName} ${command}`),
).rejects.toThrow('process.exit called');
expect(mockConsoleError).toHaveBeenCalledWith(
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
'Error: Please use --scope user to edit settings in the home directory.',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
@@ -264,7 +273,7 @@ describe('mcp add command', () => {
'mcpServers',
expect.any(Object),
);
expect(mockConsoleError).not.toHaveBeenCalled();
expect(debugLoggerErrorSpy).not.toHaveBeenCalled();
});
});
+2
View File
@@ -8,6 +8,7 @@
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { debugLogger, type MCPServerConfig } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
async function addMcpServer(
name: string,
@@ -230,5 +231,6 @@ export const addCommand: CommandModule = {
excludeTools: argv['excludeTools'] as string[] | undefined,
},
);
await exitCli();
},
};
+5 -6
View File
@@ -40,16 +40,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
GEMINI_DIR: '.gemini',
getErrorMessage: (e: unknown) =>
e instanceof Error ? e.message : String(e),
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
vi.mock('@modelcontextprotocol/sdk/client/index.js');
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
const mockedGetUserExtensionsDir =
ExtensionStorage.getUserExtensionsDir as Mock;
const mockedLoadSettings = loadSettings as Mock;
@@ -78,6 +76,7 @@ describe('mcp list command', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
mockTransport = { close: vi.fn() };
mockClient = {
+2
View File
@@ -17,6 +17,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
@@ -145,5 +146,6 @@ export const listCommand: CommandModule = {
describe: 'List all configured MCP servers',
handler: async () => {
await listMcpServers();
await exitCli();
},
};
+27 -12
View File
@@ -19,13 +19,17 @@ import { removeCommand } from './remove.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { GEMINI_DIR } from '@google/gemini-cli-core';
import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('mcp remove command', () => {
describe('unit tests with mocks', () => {
let parser: Argv;
@@ -69,13 +73,16 @@ describe('mcp remove command', () => {
});
it('should show a message if server not found', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove non-existent-server');
expect(mockSetValue).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
expect(debugLogSpy).toHaveBeenCalledWith(
'Server "non-existent-server" not found in project settings.',
);
debugLogSpy.mockRestore();
});
});
@@ -123,18 +130,20 @@ describe('mcp remove command', () => {
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove server-to-remove');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
expect(updatedContent).toContain('"server-to-keep"');
expect(updatedContent).not.toContain('"server-to-remove"');
expect(consoleSpy).toHaveBeenCalledWith(
expect(debugLogSpy).toHaveBeenCalledWith(
'Server "server-to-remove" removed from project settings.',
);
consoleSpy.mockRestore();
debugLogSpy.mockRestore();
});
it('should preserve comments when removing a server', async () => {
@@ -154,7 +163,9 @@ describe('mcp remove command', () => {
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove oldServer');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
@@ -163,7 +174,7 @@ describe('mcp remove command', () => {
expect(updatedContent).not.toContain('"oldServer"');
expect(updatedContent).toContain('// Server to remove');
consoleSpy.mockRestore();
debugLogSpy.mockRestore();
});
it('should handle removing the only server', async () => {
@@ -177,7 +188,9 @@ describe('mcp remove command', () => {
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove only-server');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
@@ -185,7 +198,7 @@ describe('mcp remove command', () => {
expect(updatedContent).not.toContain('"only-server"');
expect(updatedContent).toMatch(/"mcpServers"\s*:\s*\{\s*\}/);
consoleSpy.mockRestore();
debugLogSpy.mockRestore();
});
it('should preserve other settings when removing a server', async () => {
@@ -211,7 +224,9 @@ describe('mcp remove command', () => {
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove server1');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
@@ -222,7 +237,7 @@ describe('mcp remove command', () => {
expect(updatedContent).toContain('"theme": "dark"');
expect(updatedContent).not.toContain('"server1"');
consoleSpy.mockRestore();
debugLogSpy.mockRestore();
});
});
});
+2
View File
@@ -8,6 +8,7 @@
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
async function removeMcpServer(
name: string,
@@ -57,5 +58,6 @@ export const removeCommand: CommandModule = {
await removeMcpServer(argv['name'] as string, {
scope: argv['scope'] as string,
});
await exitCli();
},
};
+41
View File
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { exitCli } from './utils.js';
import { runExitCleanup } from '../utils/cleanup.js';
vi.mock('../utils/cleanup.js', () => ({
runExitCleanup: vi.fn(),
}));
describe('utils', () => {
const originalProcessExit = process.exit;
beforeEach(() => {
// @ts-expect-error - Mocking process.exit
process.exit = vi.fn();
});
afterEach(() => {
process.exit = originalProcessExit;
vi.clearAllMocks();
});
describe('exitCli', () => {
it('should call runExitCleanup and process.exit with default exit code 0', async () => {
await exitCli();
expect(runExitCleanup).toHaveBeenCalled();
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should call runExitCleanup and process.exit with specified exit code', async () => {
await exitCli(1);
expect(runExitCleanup).toHaveBeenCalled();
expect(process.exit).toHaveBeenCalledWith(1);
});
});
});
+12
View File
@@ -0,0 +1,12 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { runExitCleanup } from '../utils/cleanup.js';
export async function exitCli(exitCode = 0) {
await runExitCleanup();
process.exit(exitCode);
}
+68 -49
View File
@@ -5,7 +5,7 @@
*/
import { AuthType } from '@google/gemini-cli-core';
import { vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { validateAuthMethod } from './auth.js';
vi.mock('./settings.js', () => ({
@@ -17,7 +17,6 @@ vi.mock('./settings.js', () => ({
describe('validateAuthMethod', () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv('GEMINI_API_KEY', undefined);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
@@ -28,53 +27,73 @@ describe('validateAuthMethod', () => {
vi.unstubAllEnvs();
});
it('should return null for LOGIN_WITH_GOOGLE', () => {
expect(validateAuthMethod(AuthType.LOGIN_WITH_GOOGLE)).toBeNull();
});
it('should return null for CLOUD_SHELL', () => {
expect(validateAuthMethod(AuthType.CLOUD_SHELL)).toBeNull();
});
describe('USE_GEMINI', () => {
it('should return null if GEMINI_API_KEY is set', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key');
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
it('should return an error message if GEMINI_API_KEY is not set', () => {
vi.stubEnv('GEMINI_API_KEY', undefined);
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
});
describe('USE_VERTEX_AI', () => {
it('should return null if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'test-location');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return null if GOOGLE_API_KEY is set', () => {
vi.stubEnv('GOOGLE_API_KEY', 'test-api-key');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return an error message if no required environment variables are set', () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBe(
it.each([
{
description: 'should return null for LOGIN_WITH_GOOGLE',
authType: AuthType.LOGIN_WITH_GOOGLE,
envs: {},
expected: null,
},
{
description: 'should return null for COMPUTE_ADC',
authType: AuthType.COMPUTE_ADC,
envs: {},
expected: null,
},
{
description: 'should return null for USE_GEMINI if GEMINI_API_KEY is set',
authType: AuthType.USE_GEMINI,
envs: { GEMINI_API_KEY: 'test-key' },
expected: null,
},
{
description:
'should return an error message for USE_GEMINI if GEMINI_API_KEY is not set',
authType: AuthType.USE_GEMINI,
envs: {},
expected:
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
'Update your environment and try again (no reload needed if using .env)!',
},
{
description:
'should return null for USE_VERTEX_AI if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set',
authType: AuthType.USE_VERTEX_AI,
envs: {
GOOGLE_CLOUD_PROJECT: 'test-project',
GOOGLE_CLOUD_LOCATION: 'test-location',
},
expected: null,
},
{
description:
'should return null for USE_VERTEX_AI if GOOGLE_API_KEY is set',
authType: AuthType.USE_VERTEX_AI,
envs: { GOOGLE_API_KEY: 'test-api-key' },
expected: null,
},
{
description:
'should return an error message for USE_VERTEX_AI if no required environment variables are set',
authType: AuthType.USE_VERTEX_AI,
envs: {},
expected:
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!',
);
});
});
it('should return an error message for an invalid auth method', () => {
expect(validateAuthMethod('invalid-method')).toBe(
'Invalid auth method selected.',
);
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!',
},
{
description: 'should return an error message for an invalid auth method',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authType: 'invalid-method' as any,
envs: {},
expected: 'Invalid auth method selected.',
},
])('$description', ({ authType, envs, expected }) => {
for (const [key, value] of Object.entries(envs)) {
vi.stubEnv(key, value as string);
}
expect(validateAuthMethod(authType)).toBe(expected);
});
});
+7 -1
View File
@@ -11,12 +11,18 @@ export function validateAuthMethod(authMethod: string): string | null {
loadEnvironment(loadSettings().merged);
if (
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
authMethod === AuthType.CLOUD_SHELL
authMethod === AuthType.COMPUTE_ADC
) {
return null;
}
if (authMethod === AuthType.USE_GEMINI) {
if (!process.env['GEMINI_API_KEY']) {
return (
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
'Update your environment and try again (no reload needed if using .env)!'
);
}
return null;
}
@@ -4,7 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
@@ -64,106 +73,33 @@ describe('Configuration Integration Tests', () => {
}
});
describe('File Filtering Configuration', () => {
it('should load default file filtering settings', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
);
});
it('should load custom file filtering settings from configuration', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: false,
},
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
});
it('should merge user and workspace file filtering settings', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: true,
},
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
});
});
describe('Configuration Integration', () => {
it('should handle partial configuration objects gracefully', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: false,
},
};
const config = new Config(configParams);
// Specified settings should be applied
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
});
it('should handle empty configuration objects gracefully', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
describe('File Filtering and Configuration', () => {
it.each([
{
description:
'should load default file filtering settings when fileFiltering is missing',
fileFiltering: undefined,
expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
},
{
description:
'should load custom file filtering settings from configuration',
fileFiltering: { respectGitIgnore: false },
expected: false,
},
{
description:
'should respect file filtering settings from configuration',
fileFiltering: { respectGitIgnore: true },
expected: true,
},
{
description:
'should handle empty fileFiltering object gracefully and use defaults',
fileFiltering: {},
};
const config = new Config(configParams);
// All settings should use defaults
expect(config.getFileFilteringRespectGitIgnore()).toBe(
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
);
});
it('should handle missing configuration sections gracefully', async () => {
expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
},
])('$description', async ({ fileFiltering, expected }) => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
@@ -172,20 +108,26 @@ describe('Configuration Integration Tests', () => {
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
// Missing fileFiltering configuration
fileFiltering,
};
const config = new Config(configParams);
// All git-aware settings should use defaults
expect(config.getFileFilteringRespectGitIgnore()).toBe(
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
);
expect(config.getFileFilteringRespectGitIgnore()).toBe(expected);
});
});
describe('Real-world Configuration Scenarios', () => {
it('should handle a security-focused configuration', async () => {
it.each([
{
description: 'should handle a security-focused configuration',
respectGitIgnore: true,
},
{
description: 'should handle a CI/CD environment configuration',
respectGitIgnore: false,
},
])('$description', async ({ respectGitIgnore }) => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
@@ -195,32 +137,13 @@ describe('Configuration Integration Tests', () => {
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: true,
respectGitIgnore,
},
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
});
it('should handle a CI/CD environment configuration', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: false,
}, // CI might need to see all files
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
expect(config.getFileFilteringRespectGitIgnore()).toBe(respectGitIgnore);
});
});
@@ -252,139 +175,70 @@ describe('Configuration Integration Tests', () => {
parseArguments = parseArgs;
});
it('should parse --approval-mode=auto_edit correctly through the full argument parsing flow', async () => {
const originalArgv = process.argv;
try {
process.argv = [
it.each([
{
description: 'should parse --approval-mode=auto_edit correctly',
argv: [
'node',
'script.js',
'--approval-mode',
'auto_edit',
'-p',
'test',
];
const argv = await parseArguments({} as Settings);
// Verify that the argument was parsed correctly
expect(argv.approvalMode).toBe('auto_edit');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false);
],
expected: { approvalMode: 'auto_edit', prompt: 'test', yolo: false },
},
{
description: 'should parse --approval-mode=yolo correctly',
argv: ['node', 'script.js', '--approval-mode', 'yolo', '-p', 'test'],
expected: { approvalMode: 'yolo', prompt: 'test', yolo: false },
},
{
description: 'should parse --approval-mode=default correctly',
argv: ['node', 'script.js', '--approval-mode', 'default', '-p', 'test'],
expected: { approvalMode: 'default', prompt: 'test', yolo: false },
},
{
description: 'should parse legacy --yolo flag correctly',
argv: ['node', 'script.js', '--yolo', '-p', 'test'],
expected: { yolo: true, approvalMode: undefined, prompt: 'test' },
},
{
description: 'should handle no approval mode arguments',
argv: ['node', 'script.js', '-p', 'test'],
expected: { approvalMode: undefined, yolo: false, prompt: 'test' },
},
])('$description', async ({ argv, expected }) => {
const originalArgv = process.argv;
try {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
expect(parsedArgs.approvalMode).toBe(expected.approvalMode);
expect(parsedArgs.prompt).toBe(expected.prompt);
expect(parsedArgs.yolo).toBe(expected.yolo);
} finally {
process.argv = originalArgv;
}
});
it('should parse --approval-mode=yolo correctly through the full argument parsing flow', async () => {
it.each([
{
description: 'should reject invalid approval mode values',
argv: ['node', 'script.js', '--approval-mode', 'invalid_mode'],
},
{
description:
'should reject conflicting --yolo and --approval-mode flags',
argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'],
},
])('$description', async ({ argv }) => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--approval-mode',
'yolo',
'-p',
'test',
];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('yolo');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false); // Should NOT be set when using --approval-mode
} finally {
process.argv = originalArgv;
}
});
it('should parse --approval-mode=default correctly through the full argument parsing flow', async () => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--approval-mode',
'default',
'-p',
'test',
];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('default');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false);
} finally {
process.argv = originalArgv;
}
});
it('should parse legacy --yolo flag correctly', async () => {
const originalArgv = process.argv;
try {
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
const argv = await parseArguments({} as Settings);
expect(argv.yolo).toBe(true);
expect(argv.approvalMode).toBeUndefined(); // Should NOT be set when using --yolo
expect(argv.prompt).toBe('test');
} finally {
process.argv = originalArgv;
}
});
it('should reject invalid approval mode values during argument parsing', async () => {
const originalArgv = process.argv;
try {
process.argv = ['node', 'script.js', '--approval-mode', 'invalid_mode'];
// Should throw during argument parsing due to yargs validation
process.argv = argv;
await expect(parseArguments({} as Settings)).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
});
it('should reject conflicting --yolo and --approval-mode flags', async () => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--yolo',
'--approval-mode',
'default',
];
// Should throw during argument parsing due to conflict validation
await expect(parseArguments({} as Settings)).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
});
it('should handle backward compatibility with mixed scenarios', async () => {
const originalArgv = process.argv;
try {
// Test that no approval mode arguments defaults to no flags set
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBeUndefined();
expect(argv.yolo).toBe(false);
expect(argv.prompt).toBe('test');
} finally {
process.argv = originalArgv;
}
});
});
});
+292 -416
View File
@@ -9,19 +9,19 @@ import * as os from 'node:os';
import * as path from 'node:path';
import {
DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
OutputFormat,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
type ExtensionLoader,
debugLogger,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import type { Settings } from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionManager } from './extension-manager.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
@@ -112,6 +112,7 @@ vi.mock('@google/gemini-cli-core', async () => {
return Promise.resolve({
memoryContent: extensionPaths.join(',') || '',
fileCount: extensionPaths?.length || 0,
filePaths: extensionPaths,
});
},
),
@@ -146,286 +147,264 @@ afterEach(() => {
});
describe('parseArguments', () => {
it('should throw an error when both --prompt and --prompt-interactive are used together', async () => {
process.argv = [
'node',
'script.js',
'--prompt',
'test prompt',
'--prompt-interactive',
'interactive prompt',
];
it.each([
{
description: 'long flags',
argv: [
'node',
'script.js',
'--prompt',
'test prompt',
'--prompt-interactive',
'interactive prompt',
],
},
{
description: 'short flags',
argv: [
'node',
'script.js',
'-p',
'test prompt',
'-i',
'interactive prompt',
],
},
])(
'should throw an error when using conflicting prompt flags ($description)',
async ({ argv }) => {
process.argv = argv;
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
},
);
it.each([
{
description: 'should allow --prompt without --prompt-interactive',
argv: ['node', 'script.js', '--prompt', 'test prompt'],
expected: { prompt: 'test prompt', promptInteractive: undefined },
},
{
description: 'should allow --prompt-interactive without --prompt',
argv: ['node', 'script.js', '--prompt-interactive', 'interactive prompt'],
expected: { prompt: undefined, promptInteractive: 'interactive prompt' },
},
{
description: 'should allow -i flag as alias for --prompt-interactive',
argv: ['node', 'script.js', '-i', 'interactive prompt'],
expected: { prompt: undefined, promptInteractive: 'interactive prompt' },
},
])('$description', async ({ argv, expected }) => {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
expect(parsedArgs.prompt).toBe(expected.prompt);
expect(parsedArgs.promptInteractive).toBe(expected.promptInteractive);
});
describe('positional arguments and @commands', () => {
it.each([
{
description:
'should convert positional query argument to prompt by default',
argv: ['node', 'script.js', 'Hi Gemini'],
expectedQuery: 'Hi Gemini',
expectedModel: undefined,
debug: false,
},
{
description:
'should map @path to prompt (one-shot) when it starts with @',
argv: ['node', 'script.js', '@path ./file.md'],
expectedQuery: '@path ./file.md',
expectedModel: undefined,
debug: false,
},
{
description:
'should map @path to prompt even when config flags are present',
argv: [
'node',
'script.js',
'@path',
'./file.md',
'--model',
'gemini-2.5-pro',
],
expectedQuery: '@path ./file.md',
expectedModel: 'gemini-2.5-pro',
debug: false,
},
{
description:
'maps unquoted positional @path + arg to prompt (one-shot)',
argv: ['node', 'script.js', '@path', './file.md'],
expectedQuery: '@path ./file.md',
expectedModel: undefined,
debug: false,
},
{
description:
'should handle multiple @path arguments in a single command (one-shot)',
argv: [
'node',
'script.js',
'@path',
'./file1.md',
'@path',
'./file2.md',
],
expectedQuery: '@path ./file1.md @path ./file2.md',
expectedModel: undefined,
debug: false,
},
{
description:
'should handle mixed quoted and unquoted @path arguments (one-shot)',
argv: [
'node',
'script.js',
'@path ./file1.md',
'@path',
'./file2.md',
'additional text',
],
expectedQuery: '@path ./file1.md @path ./file2.md additional text',
expectedModel: undefined,
debug: false,
},
{
description: 'should map @path to prompt with ambient flags (debug)',
argv: ['node', 'script.js', '@path', './file.md', '--debug'],
expectedQuery: '@path ./file.md',
expectedModel: undefined,
debug: true,
},
{
description: 'should map @include to prompt (one-shot)',
argv: ['node', 'script.js', '@include src/'],
expectedQuery: '@include src/',
expectedModel: undefined,
debug: false,
},
{
description: 'should map @search to prompt (one-shot)',
argv: ['node', 'script.js', '@search pattern'],
expectedQuery: '@search pattern',
expectedModel: undefined,
debug: false,
},
{
description: 'should map @web to prompt (one-shot)',
argv: ['node', 'script.js', '@web query'],
expectedQuery: '@web query',
expectedModel: undefined,
debug: false,
},
{
description: 'should map @git to prompt (one-shot)',
argv: ['node', 'script.js', '@git status'],
expectedQuery: '@git status',
expectedModel: undefined,
debug: false,
},
{
description: 'should handle @command with leading whitespace',
argv: ['node', 'script.js', ' @path ./file.md'],
expectedQuery: ' @path ./file.md',
expectedModel: undefined,
debug: false,
},
])(
'$description',
async ({ argv, expectedQuery, expectedModel, debug }) => {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
expect(parsedArgs.query).toBe(expectedQuery);
expect(parsedArgs.prompt).toBe(expectedQuery);
expect(parsedArgs.promptInteractive).toBeUndefined();
if (expectedModel) {
expect(parsedArgs.model).toBe(expectedModel);
}
if (debug) {
expect(parsedArgs.debug).toBe(true);
}
},
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
it('should throw an error when using short flags -p and -i together', async () => {
process.argv = [
'node',
'script.js',
'-p',
'test prompt',
'-i',
'interactive prompt',
];
it.each([
{
description: 'long flags',
argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'],
},
{
description: 'short flags',
argv: ['node', 'script.js', '-y', '--approval-mode', 'yolo'],
},
])(
'should throw an error when using conflicting yolo/approval-mode flags ($description)',
async ({ argv }) => {
process.argv = argv;
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
mockExit.mockRestore();
mockConsoleError.mockRestore();
},
);
it('should allow --prompt without --prompt-interactive', async () => {
process.argv = ['node', 'script.js', '--prompt', 'test prompt'];
const argv = await parseArguments({} as Settings);
expect(argv.prompt).toBe('test prompt');
expect(argv.promptInteractive).toBeUndefined();
});
it('should allow --prompt-interactive without --prompt', async () => {
process.argv = [
'node',
'script.js',
'--prompt-interactive',
'interactive prompt',
];
const argv = await parseArguments({} as Settings);
expect(argv.promptInteractive).toBe('interactive prompt');
expect(argv.prompt).toBeUndefined();
});
it('should allow -i flag as alias for --prompt-interactive', async () => {
process.argv = ['node', 'script.js', '-i', 'interactive prompt'];
const argv = await parseArguments({} as Settings);
expect(argv.promptInteractive).toBe('interactive prompt');
expect(argv.prompt).toBeUndefined();
});
it('should convert positional query argument to prompt by default', async () => {
process.argv = ['node', 'script.js', 'Hi Gemini'];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe('Hi Gemini');
expect(argv.prompt).toBe('Hi Gemini');
expect(argv.promptInteractive).toBeUndefined();
});
it('should map @path to prompt (one-shot) when it starts with @', async () => {
process.argv = ['node', 'script.js', '@path ./file.md'];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe('@path ./file.md');
expect(argv.prompt).toBe('@path ./file.md');
expect(argv.promptInteractive).toBeUndefined();
});
it('should map @path to prompt even when config flags are present', async () => {
// @path queries should now go to one-shot mode regardless of other flags
process.argv = [
'node',
'script.js',
'@path',
'./file.md',
'--model',
'gemini-2.5-pro',
];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe('@path ./file.md');
expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot
expect(argv.promptInteractive).toBeUndefined();
expect(argv.model).toBe('gemini-2.5-pro');
});
it('maps unquoted positional @path + arg to prompt (one-shot)', async () => {
// Simulate: gemini @path ./file.md
process.argv = ['node', 'script.js', '@path', './file.md'];
const argv = await parseArguments({} as Settings);
// After normalization, query is a single string
expect(argv.query).toBe('@path ./file.md');
// And it's mapped to one-shot prompt when no -p/-i flags are set
expect(argv.prompt).toBe('@path ./file.md');
expect(argv.promptInteractive).toBeUndefined();
});
it('should handle multiple @path arguments in a single command (one-shot)', async () => {
// Simulate: gemini @path ./file1.md @path ./file2.md
process.argv = [
'node',
'script.js',
'@path',
'./file1.md',
'@path',
'./file2.md',
];
const argv = await parseArguments({} as Settings);
// After normalization, all arguments are joined with spaces
expect(argv.query).toBe('@path ./file1.md @path ./file2.md');
// And it's mapped to one-shot prompt
expect(argv.prompt).toBe('@path ./file1.md @path ./file2.md');
expect(argv.promptInteractive).toBeUndefined();
});
it('should handle mixed quoted and unquoted @path arguments (one-shot)', async () => {
// Simulate: gemini "@path ./file1.md" @path ./file2.md "additional text"
process.argv = [
'node',
'script.js',
'@path ./file1.md',
'@path',
'./file2.md',
'additional text',
];
const argv = await parseArguments({} as Settings);
// After normalization, all arguments are joined with spaces
expect(argv.query).toBe(
'@path ./file1.md @path ./file2.md additional text',
);
// And it's mapped to one-shot prompt
expect(argv.prompt).toBe(
'@path ./file1.md @path ./file2.md additional text',
);
expect(argv.promptInteractive).toBeUndefined();
});
it('should map @path to prompt with ambient flags (debug)', async () => {
// Ambient flags like debug should NOT affect routing
process.argv = ['node', 'script.js', '@path', './file.md', '--debug'];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe('@path ./file.md');
expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot
expect(argv.promptInteractive).toBeUndefined();
expect(argv.debug).toBe(true);
});
it('should map any @command to prompt (one-shot)', async () => {
// Test that all @commands now go to one-shot mode
const testCases = [
'@path ./file.md',
'@include src/',
'@search pattern',
'@web query',
'@git status',
];
for (const testQuery of testCases) {
process.argv = ['node', 'script.js', testQuery];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe(testQuery);
expect(argv.prompt).toBe(testQuery);
expect(argv.promptInteractive).toBeUndefined();
}
});
it('should handle @command with leading whitespace', async () => {
// Test that trim() + routing handles leading whitespace correctly
process.argv = ['node', 'script.js', ' @path ./file.md'];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe(' @path ./file.md');
expect(argv.prompt).toBe(' @path ./file.md');
expect(argv.promptInteractive).toBeUndefined();
});
it('should throw an error when both --yolo and --approval-mode are used together', async () => {
process.argv = [
'node',
'script.js',
'--yolo',
'--approval-mode',
'default',
];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
it('should throw an error when using short flags -y and --approval-mode together', async () => {
process.argv = ['node', 'script.js', '-y', '--approval-mode', 'yolo'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
it('should allow --approval-mode without --yolo', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('auto_edit');
expect(argv.yolo).toBe(false);
});
it('should allow --yolo without --approval-mode', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments({} as Settings);
expect(argv.yolo).toBe(true);
expect(argv.approvalMode).toBeUndefined();
it.each([
{
description: 'should allow --approval-mode without --yolo',
argv: ['node', 'script.js', '--approval-mode', 'auto_edit'],
expected: { approvalMode: 'auto_edit', yolo: false },
},
{
description: 'should allow --yolo without --approval-mode',
argv: ['node', 'script.js', '--yolo'],
expected: { approvalMode: undefined, yolo: true },
},
])('$description', async ({ argv, expected }) => {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
expect(parsedArgs.approvalMode).toBe(expected.approvalMode);
expect(parsedArgs.yolo).toBe(expected.yolo);
});
it('should reject invalid --approval-mode values', async () => {
@@ -438,45 +417,47 @@ describe('parseArguments', () => {
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid values:'),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should throw an error when resuming a session without prompt in non-interactive mode', async () => {
it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '--resume', 'session-id'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
try {
const argv = await parseArguments({} as Settings);
expect(argv.resume).toBe('session-id');
} finally {
process.stdin.isTTY = originalIsTTY;
}
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should return RESUME_LATEST constant when --resume is passed without a value', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = true; // Make it interactive to avoid validation error
process.argv = ['node', 'script.js', '--resume'];
try {
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
),
);
const argv = await parseArguments({} as Settings);
expect(argv.resume).toBe(RESUME_LATEST);
expect(argv.resume).toBe('latest');
} finally {
mockExit.mockRestore();
mockConsoleError.mockRestore();
process.stdin.isTTY = originalIsTTY;
}
});
@@ -1359,100 +1340,6 @@ describe('loadCliConfig model selection', () => {
});
});
describe('loadCliConfig model selection with model router', () => {
beforeEach(() => {
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.resetAllMocks();
});
it('should use auto model when useModelRouter is true and no model is provided', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: true,
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL_AUTO);
});
it('should use default model when useModelRouter is false and no model is provided', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: false,
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
});
it('should prioritize argv over useModelRouter', async () => {
process.argv = ['node', 'script.js', '--model', 'gemini-from-argv'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: true,
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe('gemini-from-argv');
});
it('should prioritize settings over useModelRouter', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: true,
},
model: {
name: 'gemini-from-settings',
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe('gemini-from-settings');
});
it('should prioritize environment variable over useModelRouter', async () => {
process.argv = ['node', 'script.js'];
vi.stubEnv('GEMINI_MODEL', 'gemini-from-env');
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: true,
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe('gemini-from-env');
});
});
describe('loadCliConfig folderTrust', () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -1547,11 +1434,13 @@ describe('loadCliConfig with includeDirectories', () => {
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
];
expect(config.getWorkspaceContext().getDirectories()).toEqual(
expect.arrayContaining(expected),
const directories = config.getWorkspaceContext().getDirectories();
expect(directories).toEqual([mockCwd]);
expect(config.getPendingIncludeDirectories()).toEqual(
expect.arrayContaining(expected.filter((dir) => dir !== mockCwd)),
);
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
expected.length,
expect(config.getPendingIncludeDirectories()).toHaveLength(
expected.length - 1,
);
});
});
@@ -1626,32 +1515,6 @@ describe('loadCliConfig useRipgrep', () => {
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getUseRipgrep()).toBe(true);
});
describe('loadCliConfig useModelRouter', () => {
it('should be true by default when useModelRouter is not set in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getUseModelRouter()).toBe(true);
});
it('should be true when useModelRouter is set to true in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { experimental: { useModelRouter: true } };
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getUseModelRouter()).toBe(true);
});
it('should be false when useModelRouter is explicitly set to false in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { experimental: { useModelRouter: false } };
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getUseModelRouter()).toBe(false);
});
});
});
describe('screenReader configuration', () => {
@@ -2215,21 +2078,30 @@ describe('Output format', () => {
});
it('should error on invalid --output-format argument', async () => {
process.argv = ['node', 'script.js', '--output-format', 'yaml'];
process.argv = ['node', 'script.js', '--output-format', 'invalid'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid values:'),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
});
@@ -2257,12 +2129,15 @@ describe('parseArguments with positional prompt', () => {
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both a positional prompt and the --prompt (-p) flag together',
),
@@ -2270,6 +2145,7 @@ describe('parseArguments with positional prompt', () => {
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should correctly parse a positional prompt to query field', async () => {
+60 -55
View File
@@ -15,7 +15,6 @@ import {
setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename,
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_FILE_FILTERING_OPTIONS,
@@ -37,6 +36,7 @@ import { getCliVersion } from '../utils/version.js';
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';
@@ -45,6 +45,7 @@ import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensio
import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { runExitCleanup } from '../utils/cleanup.js';
export interface CliArgs {
query: string | undefined;
@@ -61,7 +62,7 @@ export interface CliArgs {
experimentalAcp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | 'latest' | undefined;
resume: string | typeof RESUME_LATEST | undefined;
listSessions: boolean | undefined;
deleteSession: string | undefined;
includeDirectories: string[] | undefined;
@@ -189,7 +190,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// When --resume not passed at all: this `coerce` function is not called at all, and
// `yargsInstance.argv.resume` is undefined.
if (value === '') {
return 'latest';
return RESUME_LATEST;
}
return value;
},
@@ -238,40 +239,42 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
.deprecateOption(
'prompt',
'Use the positional prompt instead. This flag will be removed in a future version.',
)
// Ensure validation flows through .fail() for clean UX
.fail((msg, err, yargs) => {
debugLogger.error(msg || err?.message || 'Unknown error');
yargs.showHelp();
process.exit(1);
})
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
: !!query;
if (argv['prompt'] && hasPositionalQuery) {
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
}
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv.resume && !argv.prompt && !process.stdin.isTTY) {
throw new Error(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
);
}
if (argv.yolo && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
return true;
}),
),
)
// Register MCP subcommands
.command(mcpCommand);
.command(mcpCommand)
// Ensure validation flows through .fail() for clean UX
.fail((msg, err) => {
if (err) throw err;
throw new Error(msg);
})
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
: !!query;
if (argv['prompt'] && hasPositionalQuery) {
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
}
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv['yolo'] && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
if (
argv['outputFormat'] &&
!['text', 'json', 'stream-json'].includes(
argv['outputFormat'] as string,
)
) {
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
}
return true;
});
if (settings?.experimental?.extensionManagement ?? true) {
yargsInstance.command(extensionsCommand);
@@ -283,20 +286,24 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
.help()
.alias('h', 'help')
.strict()
.demandCommand(0, 0); // Allow base command to run with no subcommands
.demandCommand(0, 0) // Allow base command to run with no subcommands
.exitProcess(false);
yargsInstance.wrap(yargsInstance.terminalWidth());
const result = await yargsInstance.parse();
let result;
try {
result = await yargsInstance.parse();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
debugLogger.error(msg);
yargsInstance.showHelp();
await runExitCleanup();
process.exit(1);
}
// If yargs handled --help/--version it will have exited; nothing to do here.
// Handle case where MCP subcommands are executed - they should exit the process
// and not return to main CLI logic
if (
result._.length > 0 &&
(result._[0] === 'mcp' || result._[0] === 'extensions')
) {
// MCP commands handle their own execution and process exit
// Handle help and version flags manually since we disabled exitProcess
if (result['help'] || result['version']) {
await runExitCleanup();
process.exit(0);
}
@@ -424,9 +431,7 @@ export async function loadCliConfig(
const { memoryContent, fileCount, filePaths } =
await loadServerHierarchicalMemory(
cwd,
settings.context?.loadMemoryFromIncludeDirectories
? includeDirectories
: [],
[],
debugMode,
fileService,
extensionManager,
@@ -516,10 +521,11 @@ export async function loadCliConfig(
const hasQuery = !!argv.query;
const interactive =
!!argv.promptInteractive ||
!!argv.experimentalAcp ||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive && !argv.experimentalAcp) {
if (!interactive) {
const defaultExcludes = [
SHELL_TOOL_NAME,
EDIT_TOOL_NAME,
@@ -555,10 +561,7 @@ export async function loadCliConfig(
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const useModelRouter = settings.experimental?.useModelRouter ?? true;
const defaultModel = useModelRouter
? DEFAULT_GEMINI_MODEL_AUTO
: DEFAULT_GEMINI_MODEL;
const defaultModel = DEFAULT_GEMINI_MODEL_AUTO;
const resolvedModel: string =
argv.model ||
process.env['GEMINI_MODEL'] ||
@@ -583,6 +586,7 @@ export async function loadCliConfig(
settings.context?.loadMemoryFromIncludeDirectories || false,
debugMode,
question,
previewFeatures: settings.general?.previewFeatures,
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
@@ -627,6 +631,8 @@ export async function loadCliConfig(
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableModelAvailabilityService:
settings.experimental?.isModelAvailabilityServiceEnabled,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
@@ -648,7 +654,6 @@ export async function loadCliConfig(
output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
useModelRouter,
enableMessageBusIntegration,
codebaseInvestigatorSettings:
settings.experimental?.codebaseInvestigatorSettings,
+48 -61
View File
@@ -4,7 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, type MockedFunction } from 'vitest';
import {
vi,
type MockedFunction,
describe,
it,
expect,
beforeEach,
afterEach,
afterAll,
} from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -623,82 +632,60 @@ describe('extension tests', () => {
});
describe('id generation', () => {
it('should generate id from source for non-github git urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
it.each([
{
description: 'should generate id from source for non-github git urls',
installMetadata: {
type: 'git',
type: 'git' as const,
source: 'http://somehost.com/foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar'));
});
it('should generate id from owner/repo for github http urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
expectedIdSource: 'http://somehost.com/foo/bar',
},
{
description:
'should generate id from owner/repo for github http urls',
installMetadata: {
type: 'git',
type: 'git' as const,
source: 'http://github.com/foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from owner/repo for github ssh urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
expectedIdSource: 'https://github.com/foo/bar',
},
{
description: 'should generate id from owner/repo for github ssh urls',
installMetadata: {
type: 'git',
type: 'git' as const,
source: 'git@github.com:foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from source for github-release extension', async () => {
expectedIdSource: 'https://github.com/foo/bar',
},
{
description:
'should generate id from source for github-release extension',
installMetadata: {
type: 'github-release' as const,
source: 'https://github.com/foo/bar',
},
expectedIdSource: 'https://github.com/foo/bar',
},
{
description:
'should generate id from the original source for local extension',
installMetadata: {
type: 'local' as const,
source: '/some/path',
},
expectedIdSource: '/some/path',
},
])('$description', async ({ installMetadata, expectedIdSource }) => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'github-release',
source: 'https://github.com/foo/bar',
},
installMetadata,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from the original source for local extension', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-ext-name',
version: '1.0.0',
installMetadata: {
type: 'local',
source: '/some/path',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'local-ext-name');
expect(extension?.id).toBe(hashValue('/some/path'));
expect(extension?.id).toBe(hashValue(expectedIdSource));
});
it('should generate id from the original source for linked extensions', async () => {
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
requestConsentNonInteractive,
requestConsentInteractive,
maybeRequestConsentOrFail,
INSTALL_WARNING_MESSAGE,
} from './consent.js';
import type { ConfirmationRequest } from '../../ui/types.js';
import type { ExtensionConfig } from '../extension.js';
import { debugLogger } from '@google/gemini-cli-core';
const mockReadline = vi.hoisted(() => ({
createInterface: vi.fn().mockReturnValue({
question: vi.fn(),
close: vi.fn(),
}),
}));
// Mocking readline for non-interactive prompts
vi.mock('node:readline', () => ({
default: mockReadline,
createInterface: mockReadline.createInterface,
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
log: vi.fn(),
},
};
});
describe('consent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('requestConsentNonInteractive', () => {
it.each([
{ input: 'y', expected: true },
{ input: 'Y', expected: true },
{ input: '', expected: true },
{ input: 'n', expected: false },
{ input: 'N', expected: false },
{ input: 'yes', expected: false },
])(
'should return $expected for input "$input"',
async ({ input, expected }) => {
const questionMock = vi.fn().mockImplementation((_, callback) => {
callback(input);
});
mockReadline.createInterface.mockReturnValue({
question: questionMock,
close: vi.fn(),
});
const consent = await requestConsentNonInteractive('Test consent');
expect(debugLogger.log).toHaveBeenCalledWith('Test consent');
expect(questionMock).toHaveBeenCalledWith(
'Do you want to continue? [Y/n]: ',
expect.any(Function),
);
expect(consent).toBe(expected);
},
);
});
describe('requestConsentInteractive', () => {
it.each([
{ confirmed: true, expected: true },
{ confirmed: false, expected: false },
])(
'should resolve with $expected when user confirms with $confirmed',
async ({ confirmed, expected }) => {
const addExtensionUpdateConfirmationRequest = vi
.fn()
.mockImplementation((request: ConfirmationRequest) => {
request.onConfirm(confirmed);
});
const consent = await requestConsentInteractive(
'Test consent',
addExtensionUpdateConfirmationRequest,
);
expect(addExtensionUpdateConfirmationRequest).toHaveBeenCalledWith({
prompt: 'Test consent\n\nDo you want to continue?',
onConfirm: expect.any(Function),
});
expect(consent).toBe(expected);
},
);
});
describe('maybeRequestConsentOrFail', () => {
const baseConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
};
it('should request consent if there is no previous config', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(baseConfig, requestConsent, undefined);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should not request consent if configs are identical', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(baseConfig, requestConsent, baseConfig);
expect(requestConsent).not.toHaveBeenCalled();
});
it('should throw an error if consent is denied', async () => {
const requestConsent = vi.fn().mockResolvedValue(false);
await expect(
maybeRequestConsentOrFail(baseConfig, requestConsent, undefined),
).rejects.toThrow('Installation cancelled for "test-ext".');
});
describe('consent string generation', () => {
it('should generate a consent string with all fields', async () => {
const config: ExtensionConfig = {
...baseConfig,
mcpServers: {
server1: { command: 'npm', args: ['start'] },
server2: { httpUrl: 'https://remote.com' },
},
contextFileName: 'my-context.md',
excludeTools: ['tool1', 'tool2'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(config, requestConsent, undefined);
const expectedConsentString = [
'Installing extension "test-ext".',
INSTALL_WARNING_MESSAGE,
'This extension will run the following MCP servers:',
' * server1 (local): npm start',
' * server2 (remote): https://remote.com',
'This extension will append info to your gemini.md context using my-context.md',
'This extension will exclude the following core tools: tool1,tool2',
].join('\n');
expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
});
it('should request consent if mcpServers change', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
mcpServers: { server1: { command: 'npm', args: ['start'] } },
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if contextFileName changes', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
contextFileName: 'new-context.md',
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if excludeTools changes', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
excludeTools: ['new-tool'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
});
});
});
@@ -6,30 +6,40 @@
import * as path from 'node:path';
import fs from 'node:fs';
import * as os from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ExtensionEnablementManager, Override } from './extensionEnablement.js';
import { ExtensionStorage } from './storage.js';
vi.mock('./storage.js');
import {
coreEvents,
GEMINI_DIR,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
vi.mock('node:os', () => ({
homedir: vi.fn().mockReturnValue('/virtual-home'),
tmpdir: vi.fn().mockReturnValue('/virtual-tmp'),
}));
const inMemoryFs: { [key: string]: string } = {};
// Helper to create a temporary directory for testing
function createTestDir() {
const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
const dirPath = `/virtual-tmp/gemini-test-${Math.random().toString(36).substring(2, 15)}`;
inMemoryFs[dirPath] = ''; // Simulate directory existence
return {
path: dirPath,
cleanup: () => fs.rmSync(dirPath, { recursive: true, force: true }),
cleanup: () => {
for (const key in inMemoryFs) {
if (key.startsWith(dirPath)) {
delete inMemoryFs[key];
}
}
},
};
}
@@ -38,13 +48,55 @@ let manager: ExtensionEnablementManager;
describe('ExtensionEnablementManager', () => {
beforeEach(() => {
// Clear the in-memory file system before each test
for (const key in inMemoryFs) {
delete inMemoryFs[key];
}
expect(Object.keys(inMemoryFs).length).toBe(0); // Add this assertion
// Mock fs functions
vi.spyOn(fs, 'readFileSync').mockImplementation(
(path: fs.PathOrFileDescriptor) => {
const content = inMemoryFs[path.toString()];
if (content === undefined) {
const error = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return content;
},
);
vi.spyOn(fs, 'writeFileSync').mockImplementation(
(
path: fs.PathOrFileDescriptor,
data: string | ArrayBufferView<ArrayBufferLike>,
) => {
inMemoryFs[path.toString()] = data.toString(); // Convert ArrayBufferView to string for inMemoryFs
},
);
vi.spyOn(fs, 'mkdirSync').mockImplementation(
(
_path: fs.PathLike,
_options?: fs.MakeDirectoryOptions | fs.Mode | null,
) => undefined,
);
vi.spyOn(fs, 'mkdtempSync').mockImplementation((prefix: string) => {
const virtualPath = `/virtual-tmp/${prefix.replace(/[^a-zA-Z0-9]/g, '')}`;
return virtualPath;
});
vi.spyOn(fs, 'rmSync').mockImplementation(() => {});
testDir = createTestDir();
vi.mocked(os.homedir).mockReturnValue(path.join(testDir.path, GEMINI_DIR));
vi.mocked(ExtensionStorage.getUserExtensionsDir).mockReturnValue(
path.join(testDir.path, GEMINI_DIR),
);
manager = new ExtensionEnablementManager();
});
afterEach(() => {
testDir.cleanup();
vi.restoreAllMocks();
// Reset the singleton instance for test isolation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ExtensionEnablementManager as any).instance = undefined;
@@ -92,7 +144,7 @@ describe('ExtensionEnablementManager', () => {
);
});
it('should handle', () => {
it('should handle overlapping rules correctly', () => {
manager.enable('ext-test', true, '/home/user/projects');
manager.disable('ext-test', false, '/home/user/projects/my-app');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
@@ -104,6 +156,46 @@ describe('ExtensionEnablementManager', () => {
});
});
describe('remove', () => {
it('should remove an extension from the config', () => {
manager.enable('ext-test', true, '/path/to/dir');
const config = manager.readConfig();
expect(config['ext-test']).toBeDefined();
manager.remove('ext-test');
const newConfig = manager.readConfig();
expect(newConfig['ext-test']).toBeUndefined();
});
it('should not throw when removing a non-existent extension', () => {
const config = manager.readConfig();
expect(config['ext-test']).toBeUndefined();
expect(() => manager.remove('ext-test')).not.toThrow();
});
});
describe('readConfig', () => {
it('should return an empty object if the config file is corrupted', () => {
const configPath = path.join(
testDir.path,
GEMINI_DIR,
'extension-enablement.json',
);
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, 'not a json');
const config = manager.readConfig();
expect(config).toEqual({});
});
it('should return an empty object on generic read error', () => {
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
throw new Error('Read error');
});
const config = manager.readConfig();
expect(config).toEqual({});
});
});
describe('includeSubdirs', () => {
it('should add a glob when enabling with includeSubdirs', () => {
manager.enable('ext-test', true, '/path/to/dir');
@@ -223,7 +315,7 @@ describe('ExtensionEnablementManager', () => {
});
});
it('should enable a path based on an enable override', () => {
it('should correctly prioritize more specific enable rules', () => {
manager.disable('ext-test', true, '/Users/chrstn');
manager.enable('ext-test', true, '/Users/chrstn/gemini-cli');
@@ -232,7 +324,7 @@ describe('ExtensionEnablementManager', () => {
);
});
it('should ignore subdirs', () => {
it('should not disable subdirectories if includeSubdirs is false', () => {
manager.disable('ext-test', false, '/Users/chrstn');
expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
true,
@@ -348,6 +440,13 @@ describe('Override', () => {
});
it('should create an override from a file rule', () => {
const override = Override.fromFileRule('/path/to/dir/');
expect(override.baseRule).toBe('/path/to/dir/');
expect(override.isDisable).toBe(false);
expect(override.includeSubdirs).toBe(false);
});
it('should create an override from a file rule without a trailing slash', () => {
const override = Override.fromFileRule('/path/to/dir');
expect(override.baseRule).toBe('/path/to/dir');
expect(override.isDisable).toBe(false);
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import {
getEnvContents,
maybePromptForSettings,
promptForSetting,
type ExtensionSetting,
@@ -188,6 +189,83 @@ describe('extensionSettings', () => {
expect(actualContent).toBe(expectedContent);
});
it('should clear settings if new config has no settings', async () => {
const previousConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{
name: 's2',
description: 'd2',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [],
};
const previousSettings = {
VAR1: 'previous-VAR1',
SENSITIVE_VAR: 'secret',
};
keychainData['SENSITIVE_VAR'] = 'secret';
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1');
await maybePromptForSettings(
newConfig,
'12345',
mockRequestSetting,
previousConfig,
previousSettings,
);
expect(mockRequestSetting).not.toHaveBeenCalled();
const actualContent = await fsPromises.readFile(envPath, 'utf-8');
expect(actualContent).toBe('');
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith(
'SENSITIVE_VAR',
);
});
it('should remove sensitive settings from keychain', async () => {
const previousConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{
name: 's1',
description: 'd1',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [],
};
const previousSettings = { SENSITIVE_VAR: 'secret' };
keychainData['SENSITIVE_VAR'] = 'secret';
await maybePromptForSettings(
newConfig,
'12345',
mockRequestSetting,
previousConfig,
previousSettings,
);
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith(
'SENSITIVE_VAR',
);
});
it('should remove settings that are no longer in the config', async () => {
const previousConfig: ExtensionConfig = {
name: 'test-ext',
@@ -343,5 +421,65 @@ describe('extensionSettings', () => {
});
expect(result).toBe(promptValue);
});
it('should return undefined if the user cancels the prompt', async () => {
vi.mocked(prompts).mockResolvedValue({ value: undefined });
const result = await promptForSetting({
name: 'Test',
description: 'Test desc',
envVar: 'TEST_VAR',
});
expect(result).toBeUndefined();
});
});
describe('getEnvContents', () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{
name: 's2',
description: 'd2',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
it('should return combined contents from .env and keychain', async () => {
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=value1');
keychainData['SENSITIVE_VAR'] = 'secret';
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({
VAR1: 'value1',
SENSITIVE_VAR: 'secret',
});
});
it('should return an empty object if no settings are defined', async () => {
const contents = await getEnvContents(
{ name: 'test-ext', version: '1.0.0' },
'12345',
);
expect(contents).toEqual({});
});
it('should return only keychain contents if .env file does not exist', async () => {
keychainData['SENSITIVE_VAR'] = 'secret';
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({ SENSITIVE_VAR: 'secret' });
});
it('should return only .env contents if keychain is empty', async () => {
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=value1');
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({ VAR1: 'value1' });
});
});
});
+294 -409
View File
@@ -4,484 +4,369 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockedFunction,
} from 'vitest';
import {
checkForExtensionUpdate,
cloneFromGit,
extractFile,
findReleaseAsset,
fetchReleaseFromGithub,
tryParseGithubUrl,
fetchReleaseFromGithub,
checkForExtensionUpdate,
downloadFromGitHubRelease,
findReleaseAsset,
downloadFile,
extractFile,
} from './github.js';
import { simpleGit, type SimpleGit } from 'simple-git';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as https from 'node:https';
import * as tar from 'tar';
import * as archiver from 'archiver';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { ExtensionManager } from '../extension-manager.js';
import { loadSettings } from '../settings.js';
import type { ExtensionSetting } from './extensionSettings.js';
import * as extract from 'extract-zip';
import type { ExtensionManager } from '../extension-manager.js';
import { fetchJson } from './github_fetch.js';
import { EventEmitter } from 'node:events';
import type {
GeminiCLIExtension,
ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import type { ExtensionConfig } from '../extension.js';
const mockPlatform = vi.hoisted(() => vi.fn());
const mockArch = vi.hoisted(() => vi.fn());
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>();
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
platform: mockPlatform,
arch: mockArch,
Storage: {
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/settings.json'),
getGlobalGeminiDir: vi.fn().mockReturnValue('/mock/.gemini'),
},
debugLogger: {
error: vi.fn(),
log: vi.fn(),
},
};
});
vi.mock('simple-git');
vi.mock('node:os');
vi.mock('node:fs');
vi.mock('node:https');
vi.mock('tar');
vi.mock('extract-zip');
vi.mock('./github_fetch.js');
vi.mock('../extension-manager.js');
// Mock settings.ts to avoid top-level side effects if possible, or just rely on Storage mock
vi.mock('../settings.js', () => ({
loadSettings: vi.fn(),
USER_SETTINGS_PATH: '/mock/settings.json',
}));
const fetchJsonMock = vi.hoisted(() => vi.fn());
vi.mock('./github_fetch.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./github_fetch.js')>();
return {
...actual,
fetchJson: fetchJsonMock,
};
});
describe('git extension helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
describe('github.ts', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('cloneFromGit', () => {
const mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
let mockGit: {
clone: ReturnType<typeof vi.fn>;
getRemotes: ReturnType<typeof vi.fn>;
fetch: ReturnType<typeof vi.fn>;
checkout: ReturnType<typeof vi.fn>;
listRemote: ReturnType<typeof vi.fn>;
revparse: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
});
it('should clone, fetch and checkout a repo', async () => {
const installMetadata = {
source: 'http://my-repo.com',
ref: 'my-ref',
type: 'git' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
await cloneFromGit(installMetadata, destination);
await cloneFromGit(
{
type: 'git',
source: 'https://github.com/owner/repo.git',
ref: 'v1.0.0',
},
'/dest',
);
expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [
'--depth',
'1',
]);
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref');
expect(mockGit.clone).toHaveBeenCalledWith(
'https://github.com/owner/repo.git',
'./',
['--depth', '1'],
);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0');
expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
});
it('should use HEAD if ref is not provided', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
await cloneFromGit(installMetadata, destination);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'HEAD');
});
it('should throw if no remotes are found', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
it('should throw if no remotes found', async () => {
mockGit.getRemotes.mockResolvedValue([]);
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
'Failed to clone Git repository from http://my-repo.com',
);
await expect(
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
).rejects.toThrow('Unable to find any remotes');
});
it('should throw on clone error', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
mockGit.clone.mockRejectedValue(new Error('clone failed'));
mockGit.clone.mockRejectedValue(new Error('Clone failed'));
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
'Failed to clone Git repository from http://my-repo.com',
);
await expect(
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
).rejects.toThrow('Failed to clone Git repository');
});
});
describe('checkForExtensionUpdate', () => {
const mockGit = {
getRemotes: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<
(consent: string) => Promise<boolean>
>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let tempHomeDir: string;
let tempWorkspaceDir: string;
beforeEach(() => {
tempHomeDir = fsSync.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fsSync.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
mockRequestConsent = vi.fn();
mockRequestConsent.mockResolvedValue(true);
mockPromptForSettings = vi.fn();
mockPromptForSettings.mockResolvedValue('');
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadSettings(tempWorkspaceDir).merged,
});
describe('tryParseGithubUrl', () => {
it.each([
['https://github.com/owner/repo', 'owner', 'repo'],
['https://github.com/owner/repo.git', 'owner', 'repo'],
['git@github.com:owner/repo.git', 'owner', 'repo'],
['owner/repo', 'owner', 'repo'],
])('should parse %s to %s/%s', (url, owner, repo) => {
expect(tryParseGithubUrl(url)).toEqual({ owner, repo });
});
it.each([
{
testName: 'should return NOT_UPDATABLE for non-git extensions',
extension: {
installMetadata: { type: 'link', source: '' },
},
mockSetup: () => {},
expected: ExtensionUpdateState.NOT_UPDATABLE,
},
{
testName: 'should return ERROR if no remotes found',
extension: {
installMetadata: { type: 'git', source: '' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([]);
},
expected: ExtensionUpdateState.ERROR,
},
{
testName:
'should return UPDATE_AVAILABLE when remote hash is different',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
},
expected: ExtensionUpdateState.UPDATE_AVAILABLE,
},
{
testName:
'should return UP_TO_DATE when remote and local hashes are the same',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
mockGit.revparse.mockResolvedValue('same-hash');
},
expected: ExtensionUpdateState.UP_TO_DATE,
},
{
testName: 'should return ERROR on git error',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
},
expected: ExtensionUpdateState.ERROR,
},
])('$testName', async ({ extension, mockSetup, expected }) => {
const fullExtension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
contextFiles: [],
...extension,
} as unknown as GeminiCLIExtension;
mockSetup();
const result = await checkForExtensionUpdate(
fullExtension,
extensionManager,
'https://gitlab.com/owner/repo',
'https://my-git-host.com/owner/group/repo',
'git@gitlab.com:some-group/some-project/some-repo.git',
])('should return null for non-GitHub URLs', (url) => {
expect(tryParseGithubUrl(url)).toBeNull();
});
it('should throw for invalid formats', () => {
expect(() => tryParseGithubUrl('invalid')).toThrow(
'Invalid GitHub repository source',
);
expect(result).toBe(expected);
});
});
describe('fetchReleaseFromGithub', () => {
it.each([
{
ref: undefined,
allowPreRelease: true,
mockedResponse: [{ tag_name: 'v1.0.0-alpha' }, { tag_name: 'v0.9.0' }],
expectedUrl:
'https://api.github.com/repos/owner/repo/releases?per_page=1',
expectedResult: { tag_name: 'v1.0.0-alpha' },
},
{
ref: undefined,
allowPreRelease: false,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
expectedResult: { tag_name: 'v0.9.0' },
},
{
ref: 'v0.9.0',
allowPreRelease: undefined,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl:
'https://api.github.com/repos/owner/repo/releases/tags/v0.9.0',
expectedResult: { tag_name: 'v0.9.0' },
},
{
ref: undefined,
allowPreRelease: undefined,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
expectedResult: { tag_name: 'v0.9.0' },
},
])(
'should fetch release with ref=$ref and allowPreRelease=$allowPreRelease',
async ({
ref,
allowPreRelease,
mockedResponse,
expectedUrl,
expectedResult,
}) => {
fetchJsonMock.mockResolvedValueOnce(mockedResponse);
it('should fetch latest release if no ref provided', async () => {
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
const result = await fetchReleaseFromGithub(
'owner',
'repo',
ref,
allowPreRelease,
);
await fetchReleaseFromGithub('owner', 'repo');
expect(fetchJsonMock).toHaveBeenCalledWith(expectedUrl);
expect(result).toEqual(expectedResult);
},
);
expect(fetchJson).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/latest',
);
});
it('should fetch specific ref if provided', async () => {
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0');
expect(fetchJson).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0',
);
});
it('should handle pre-releases if allowed', async () => {
vi.mocked(fetchJson).mockResolvedValueOnce([{ tag_name: 'v1.0.0-beta' }]);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
true,
);
expect(result).toEqual({ tag_name: 'v1.0.0-beta' });
});
it('should return null if no releases found', async () => {
vi.mocked(fetchJson).mockResolvedValueOnce([]);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
true,
);
expect(result).toBeNull();
});
});
describe('checkForExtensionUpdate', () => {
let mockExtensionManager: ExtensionManager;
let mockGit: {
getRemotes: ReturnType<typeof vi.fn>;
listRemote: ReturnType<typeof vi.fn>;
revparse: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockExtensionManager = {
loadExtensionConfig: vi.fn(),
} as unknown as ExtensionManager;
mockGit = {
getRemotes: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
});
it('should return NOT_UPDATABLE for non-git/non-release extensions', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
version: '1.0.0',
} as unknown as ExtensionConfig);
const linkExt = {
installMetadata: { type: 'link' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(linkExt, mockExtensionManager)).toBe(
ExtensionUpdateState.NOT_UPDATABLE,
);
});
it('should return UPDATE_AVAILABLE if git remote hash differs', async () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'url' } },
]);
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
const ext = {
path: '/path',
installMetadata: { type: 'git', source: 'url' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
});
it('should return UP_TO_DATE if git remote hash matches', async () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'url' } },
]);
mockGit.listRemote.mockResolvedValue('hash\tHEAD');
mockGit.revparse.mockResolvedValue('hash');
const ext = {
path: '/path',
installMetadata: { type: 'git', source: 'url' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
ExtensionUpdateState.UP_TO_DATE,
);
});
});
describe('downloadFromGitHubRelease', () => {
it('should fail if no release data found', async () => {
// Mock fetchJson to throw for latest release check
vi.mocked(fetchJson).mockRejectedValue(new Error('Not found'));
const result = await downloadFromGitHubRelease(
{
type: 'github-release',
source: 'owner/repo',
ref: 'v1',
} as unknown as ExtensionInstallMetadata,
'/dest',
{ owner: 'owner', repo: 'repo' },
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.failureReason).toBe('failed to fetch release data');
}
});
});
describe('findReleaseAsset', () => {
const assets = [
{ name: 'darwin.arm64.extension.tar.gz', url: 'url1' },
{ name: 'darwin.x64.extension.tar.gz', url: 'url2' },
{ name: 'linux.x64.extension.tar.gz', url: 'url3' },
{ name: 'win32.x64.extension.tar.gz', url: 'url4' },
{ name: 'extension-generic.tar.gz', url: 'url5' },
];
it.each([
{ platform: 'darwin', arch: 'arm64', expected: assets[0] },
{ platform: 'linux', arch: 'arm64', expected: assets[2] },
{ platform: 'sunos', arch: 'x64', expected: undefined },
])(
'should find asset matching platform and architecture',
({ platform, arch, expected }) => {
mockPlatform.mockReturnValue(platform);
mockArch.mockReturnValue(arch);
const result = findReleaseAsset(assets);
expect(result).toEqual(expected);
},
);
it('should find generic asset if it is the only one', () => {
const singleAsset = [{ name: 'extension.tar.gz', url: 'aurl5' }];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(singleAsset);
expect(result).toEqual(singleAsset[0]);
it('should find platform/arch specific asset', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(os.arch).mockReturnValue('arm64');
const assets = [
{ name: 'darwin.arm64.tar.gz', url: 'url1' },
{ name: 'linux.x64.tar.gz', url: 'url2' },
];
expect(findReleaseAsset(assets)).toEqual(assets[0]);
});
it('should return undefined if multiple generic assets exist', () => {
const multipleGenericAssets = [
{ name: 'extension-1.tar.gz', url: 'aurl1' },
{ name: 'extension-2.tar.gz', url: 'aurl2' },
];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(multipleGenericAssets);
expect(result).toBeUndefined();
it('should find generic asset', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
const assets = [{ name: 'generic.tar.gz', url: 'url' }];
expect(findReleaseAsset(assets)).toEqual(assets[0]);
});
});
describe('parseGitHubRepoForReleases', () => {
it.each([
{
source: 'https://github.com/owner/repo.git',
owner: 'owner',
repo: 'repo',
},
{
source: 'https://github.com/owner/repo',
owner: 'owner',
repo: 'repo',
},
{
source: 'https://github.com/owner/repo/',
owner: 'owner',
repo: 'repo',
},
{
source: 'git@github.com:owner/repo.git',
owner: 'owner',
repo: 'repo',
},
{ source: 'owner/repo', owner: 'owner', repo: 'repo' },
{ source: 'owner/repo.git', owner: 'owner', repo: 'repo' },
])(
'should parse owner and repo from $source',
({ source, owner, repo }) => {
const result = tryParseGithubUrl(source)!;
expect(result.owner).toBe(owner);
expect(result.repo).toBe(repo);
},
);
describe('downloadFile', () => {
it('should download file successfully', async () => {
const mockReq = new EventEmitter();
const mockRes =
new EventEmitter() as unknown as import('node:http').IncomingMessage;
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
it('should return null on a non-GitHub URL', () => {
const source = 'https://example.com/owner/repo.git';
expect(tryParseGithubUrl(source)).toBe(null);
vi.mocked(https.get).mockImplementation((url, options, cb) => {
if (typeof options === 'function') {
cb = options;
}
if (cb) cb(mockRes);
return mockReq as unknown as import('node:http').ClientRequest;
});
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
const promise = downloadFile('url', '/dest');
mockRes.emit('end');
mockStream.emit('finish');
await expect(promise).resolves.toBeUndefined();
});
it.each([
{ source: 'invalid-format' },
{ source: 'https://github.com/owner/repo/extra' },
])(
'should throw error for invalid source format: $source',
({ source }) => {
expect(() => tryParseGithubUrl(source)).toThrow(
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
);
},
);
it('should fail on non-200 status', async () => {
const mockReq = new EventEmitter();
const mockRes =
new EventEmitter() as unknown as import('node:http').IncomingMessage;
Object.assign(mockRes, { statusCode: 404 });
vi.mocked(https.get).mockImplementation((url, options, cb) => {
if (typeof options === 'function') {
cb = options;
}
if (cb) cb(mockRes);
return mockReq as unknown as import('node:http').ClientRequest;
});
await expect(downloadFile('url', '/dest')).rejects.toThrow(
'Request failed with status code 404',
);
});
});
describe('extractFile', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
it('should extract tar.gz using tar', async () => {
await extractFile('file.tar.gz', '/dest');
expect(tar.x).toHaveBeenCalled();
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
it('should extract zip using extract-zip', async () => {
vi.mocked(extract.default || extract).mockResolvedValue(undefined);
await extractFile('file.zip', '/dest');
// Check if extract was called. Note: extract-zip export might be default or named depending on mock
});
it('should extract a .tar.gz file', async () => {
const archivePath = path.join(tempDir, 'test.tar.gz');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello tar');
// Create the tar.gz file
await tar.c(
{
gzip: true,
file: archivePath,
cwd: tempDir,
},
['test.txt'],
it('should throw for unsupported extensions', async () => {
await expect(extractFile('file.txt', '/dest')).rejects.toThrow(
'Unsupported file extension',
);
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello tar');
});
it('should extract a .zip file', async () => {
const archivePath = path.join(tempDir, 'test.zip');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello zip');
// Create the zip file
const output = fsSync.createWriteStream(archivePath);
const archive = archiver.create('zip');
const streamFinished = new Promise((resolve, reject) => {
output.on('close', () => resolve(null));
archive.on('error', reject);
});
archive.pipe(output);
archive.file(dummyFilePath, { name: 'test.txt' });
await archive.finalize();
await streamFinished;
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello zip');
});
it('should throw an error for unsupported file types', async () => {
const unsupportedFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(unsupportedFilePath, 'some content');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
await expect(
extractFile(unsupportedFilePath, extractionDest),
).rejects.toThrow('Unsupported file extension for extraction:');
});
});
});
+20 -5
View File
@@ -84,12 +84,27 @@ export interface GithubRepoInfo {
}
export function tryParseGithubUrl(source: string): GithubRepoInfo | null {
// First step in normalizing a github ssh URI to the https form.
if (source.startsWith('git@github.com:')) {
source = source.replace('git@github.com:', '');
// Handle SCP-style SSH URLs.
if (source.startsWith('git@')) {
if (source.startsWith('git@github.com:')) {
// It's a GitHub SSH URL, so normalize it for the URL parser.
source = source.replace('git@github.com:', '');
} else {
// It's another provider's SSH URL (e.g., gitlab), so not a GitHub repo.
return null;
}
}
// Default to a github repo path, so `source` can be just an org/repo
const parsedUrl = URL.parse(source, 'https://github.com');
let parsedUrl: URL;
try {
// Use the standard URL constructor for backward compatibility.
parsedUrl = new URL(source, 'https://github.com');
} catch (e) {
// Throw a TypeError to maintain a consistent error contract for invalid URLs.
// This avoids a breaking change for consumers who might expect a TypeError.
throw new TypeError(`Invalid repo URL: ${source}`, { cause: e });
}
if (!parsedUrl) {
throw new Error(`Invalid repo URL: ${source}`);
}
@@ -457,7 +472,7 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined {
return undefined;
}
async function downloadFile(url: string, dest: string): Promise<void> {
export async function downloadFile(url: string, dest: string): Promise<void> {
const headers: {
'User-agent': string;
Accept: string;
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExtensionStorage } from './storage.js';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs';
import {
EXTENSION_SETTINGS_FILENAME,
EXTENSIONS_CONFIG_FILENAME,
} from './variables.js';
import { Storage } from '@google/gemini-cli-core';
vi.mock('node:os');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return {
...actual,
promises: {
...actual.promises,
mkdtemp: vi.fn(),
},
};
});
vi.mock('@google/gemini-cli-core');
describe('ExtensionStorage', () => {
const mockHomeDir = '/mock/home';
const extensionName = 'test-extension';
let storage: ExtensionStorage;
beforeEach(() => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(Storage).mockImplementation(
() =>
({
getExtensionsDir: () =>
path.join(mockHomeDir, '.gemini', 'extensions'),
}) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
);
storage = new ExtensionStorage(extensionName);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return the correct extension directory', () => {
const expectedDir = path.join(
mockHomeDir,
'.gemini',
'extensions',
extensionName,
);
expect(storage.getExtensionDir()).toBe(expectedDir);
});
it('should return the correct config path', () => {
const expectedPath = path.join(
mockHomeDir,
'.gemini',
'extensions',
extensionName,
EXTENSIONS_CONFIG_FILENAME, // EXTENSIONS_CONFIG_FILENAME
);
expect(storage.getConfigPath()).toBe(expectedPath);
});
it('should return the correct env file path', () => {
const expectedPath = path.join(
mockHomeDir,
'.gemini',
'extensions',
extensionName,
EXTENSION_SETTINGS_FILENAME, // EXTENSION_SETTINGS_FILENAME
);
expect(storage.getEnvFilePath()).toBe(expectedPath);
});
it('should return the correct user extensions directory', () => {
const expectedDir = path.join(mockHomeDir, '.gemini', 'extensions');
expect(ExtensionStorage.getUserExtensionsDir()).toBe(expectedDir);
});
it('should create a temporary directory', async () => {
const mockTmpDir = '/tmp/gemini-extension-123';
vi.mocked(fs.promises.mkdtemp).mockResolvedValue(mockTmpDir);
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
const result = await ExtensionStorage.createTmpDir();
expect(fs.promises.mkdtemp).toHaveBeenCalledWith(
path.join('/tmp', 'gemini-extension'),
);
expect(result).toBe(mockTmpDir);
});
});
+281 -391
View File
@@ -4,448 +4,338 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, type MockedFunction } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
import { GEMINI_DIR, KeychainTokenStorage } from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from '../trustedFolders.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { createExtension } from '../../test-utils/createExtension.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
} from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { loadSettings } from '../settings.js';
import type { ExtensionSetting } from './extensionSettings.js';
updateExtension,
updateAllUpdatableExtensions,
checkForAllExtensionUpdates,
} from './update.js';
import {
ExtensionUpdateState,
type ExtensionUpdateStatus,
} from '../../ui/state/extensions.js';
import { ExtensionStorage } from './storage.js';
import { copyExtension } from '../extension-manager.js';
import { checkForExtensionUpdate } from './github.js';
import { loadInstallMetadata } from '../extension.js';
import * as fs from 'node:fs';
import type { ExtensionManager } from '../extension-manager.js';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
const mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
// Not a part of the actual API, but we need to use this to do the correct
// file system interactions.
path: vi.fn(),
};
vi.mock('simple-git', () => ({
simpleGit: vi.fn((path: string) => {
mockGit.path.mockReturnValue(path);
return mockGit;
}),
// Mock dependencies
vi.mock('./storage.js', () => ({
ExtensionStorage: {
createTmpDir: vi.fn(),
},
}));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
vi.mock('../trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
vi.mock('../extension-manager.js', () => ({
copyExtension: vi.fn(),
// We don't need to mock the class implementation if we pass a mock instance
}));
const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
vi.mock('./github.js', () => ({
checkForExtensionUpdate: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
vi.mock('../extension.js', () => ({
loadInstallMetadata: vi.fn(),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
logExtensionInstallEvent: mockLogExtensionInstallEvent,
logExtensionUninstall: mockLogExtensionUninstall,
ExtensionInstallEvent: vi.fn(),
ExtensionUninstallEvent: vi.fn(),
KeychainTokenStorage: vi.fn().mockImplementation(() => ({
getSecret: vi.fn(),
setSecret: vi.fn(),
deleteSecret: vi.fn(),
listSecrets: vi.fn(),
isAvailable: vi.fn().mockResolvedValue(true),
})),
promises: {
...actual.promises,
rm: vi.fn(),
},
};
});
interface MockKeychainStorage {
getSecret: ReturnType<typeof vi.fn>;
setSecret: ReturnType<typeof vi.fn>;
deleteSecret: ReturnType<typeof vi.fn>;
listSecrets: ReturnType<typeof vi.fn>;
isAvailable: ReturnType<typeof vi.fn>;
}
describe('update tests', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<(consent: string) => Promise<boolean>>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let mockKeychainStorage: MockKeychainStorage;
let keychainData: Record<string, string>;
describe('Extension Update Logic', () => {
let mockExtensionManager: ExtensionManager;
let mockDispatch: ReturnType<typeof vi.fn>;
const mockExtension: GeminiCLIExtension = {
name: 'test-extension',
version: '1.0.0',
path: '/path/to/extension',
} as GeminiCLIExtension;
beforeEach(() => {
vi.clearAllMocks();
keychainData = {};
mockKeychainStorage = {
getSecret: vi
.fn()
.mockImplementation(async (key: string) => keychainData[key] || null),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
};
(
KeychainTokenStorage as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockKeychainStorage);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
Object.values(mockGit).forEach((fn) => fn.mockReset());
mockRequestConsent = vi.fn();
mockRequestConsent.mockResolvedValue(true);
mockPromptForSettings = vi.fn();
mockPromptForSettings.mockResolvedValue('');
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadSettings(tempWorkspaceDir).merged,
});
});
mockExtensionManager = {
loadExtensionConfig: vi.fn(),
installOrUpdateExtension: vi.fn(),
} as unknown as ExtensionManager;
mockDispatch = vi.fn();
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
// Default mock behaviors
vi.mocked(ExtensionStorage.createTmpDir).mockResolvedValue('/tmp/mock-dir');
vi.mocked(loadInstallMetadata).mockReturnValue({
source: 'https://example.com/repo.git',
type: 'git',
});
});
describe('updateExtension', () => {
it('should update a git-installed extension', async () => {
const gitUrl = 'https://github.com/google/gemini-extensions.git';
const extensionName = 'gemini-extensions';
const targetExtDir = path.join(userExtensionsDir, extensionName);
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
fs.mkdirSync(targetExtDir, { recursive: true });
fs.writeFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }),
it('should return undefined if state is already UPDATING', async () => {
const result = await updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATING,
mockDispatch,
);
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: gitUrl, type: 'git' }),
);
mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName)!;
const updateInfo = await updateExtension(
extension!,
extensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
() => {},
);
expect(updateInfo).toEqual({
name: 'gemini-extensions',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
const updatedConfig = JSON.parse(
fs.readFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
'utf-8',
),
);
expect(updatedConfig.version).toBe('1.1.0');
expect(result).toBeUndefined();
expect(mockDispatch).not.toHaveBeenCalled();
});
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
const extensionName = 'test-extension';
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
it('should throw error and set state to ERROR if install metadata type is unknown', async () => {
vi.mocked(loadInstallMetadata).mockReturnValue({
type: undefined,
} as unknown as import('@google/gemini-cli-core').ExtensionInstallMetadata);
mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName)!;
await updateExtension(
extension!,
extensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.UPDATING,
},
});
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
},
});
});
it('should call setExtensionUpdateState with ERROR on failure', async () => {
const extensionName = 'test-extension';
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName)!;
await expect(
updateExtension(
extension!,
extensionManager,
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
mockDispatch,
),
).rejects.toThrow();
).rejects.toThrow('type is unknown');
expect(dispatch).toHaveBeenCalledWith({
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
name: mockExtension.name,
state: ExtensionUpdateState.UPDATING,
},
});
expect(dispatch).toHaveBeenCalledWith({
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
name: mockExtension.name,
state: ExtensionUpdateState.ERROR,
},
});
});
it('should throw error and set state to UP_TO_DATE if extension is linked', async () => {
vi.mocked(loadInstallMetadata).mockReturnValue({
type: 'link',
source: '',
});
await expect(
updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
),
).rejects.toThrow('Extension is linked');
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should successfully update extension and set state to UPDATED_NEEDS_RESTART by default', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'test-extension',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({
...mockExtension,
version: '1.1.0',
});
const result = await updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
);
expect(mockExtensionManager.installOrUpdateExtension).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
},
});
expect(result).toEqual({
name: 'test-extension',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/mock-dir', {
recursive: true,
force: true,
});
});
it('should set state to UPDATED if enableExtensionReloading is true', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'test-extension',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({
...mockExtension,
version: '1.1.0',
});
await updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
true, // enableExtensionReloading
);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UPDATED,
},
});
});
it('should rollback and set state to ERROR if installation fails', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'test-extension',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockRejectedValue(new Error('Install failed'));
await expect(
updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
),
).rejects.toThrow('Updated extension not found after installation');
expect(copyExtension).toHaveBeenCalledWith(
'/tmp/mock-dir',
mockExtension.path,
);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.ERROR,
},
});
expect(fs.promises.rm).toHaveBeenCalled();
});
});
describe('updateAllUpdatableExtensions', () => {
it('should update all extensions with UPDATE_AVAILABLE status', async () => {
const extensions: GeminiCLIExtension[] = [
{ ...mockExtension, name: 'ext1' },
{ ...mockExtension, name: 'ext2' },
{ ...mockExtension, name: 'ext3' },
];
const extensionsState = new Map([
['ext1', { status: ExtensionUpdateState.UPDATE_AVAILABLE }],
['ext2', { status: ExtensionUpdateState.UP_TO_DATE }],
['ext3', { status: ExtensionUpdateState.UPDATE_AVAILABLE }],
]);
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'ext',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({ ...mockExtension, version: '1.1.0' });
const results = await updateAllUpdatableExtensions(
extensions,
extensionsState as Map<string, ExtensionUpdateStatus>,
mockExtensionManager,
mockDispatch,
);
expect(results).toHaveLength(2);
expect(results.map((r) => r.name)).toEqual(['ext1', 'ext3']);
expect(
mockExtensionManager.installOrUpdateExtension,
).toHaveBeenCalledTimes(2);
});
});
describe('checkForAllExtensionUpdates', () => {
it('should return UpdateAvailable for a git extension with updates', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
it('should dispatch BATCH_CHECK_START and BATCH_CHECK_END', async () => {
await checkForAllExtensionUpdates([], mockExtensionManager, mockDispatch);
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
mockGit.revparse.mockResolvedValue('localHash');
expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_START' });
expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_END' });
});
it('should set state to NOT_UPDATABLE if no install metadata', async () => {
const extensions: GeminiCLIExtension[] = [
{ ...mockExtension, installMetadata: undefined },
];
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
extensions,
mockExtensionManager,
mockDispatch,
);
expect(dispatch).toHaveBeenCalledWith({
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'test-extension',
name: mockExtension.name,
state: ExtensionUpdateState.NOT_UPDATABLE,
},
});
});
it('should check for updates and update state', async () => {
const extensions: GeminiCLIExtension[] = [
{ ...mockExtension, installMetadata: { type: 'git', source: '...' } },
];
vi.mocked(checkForExtensionUpdate).mockResolvedValue(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
await checkForAllExtensionUpdates(
extensions,
mockExtensionManager,
mockDispatch,
);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.CHECKING_FOR_UPDATES,
},
});
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
});
it('should return UpToDate for a git extension with no updates', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
mockGit.revparse.mockResolvedValue('sameHash');
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'test-extension',
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should return UpToDate for a local extension with no updates', async () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'my-local-ext',
version: '1.0.0',
});
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'local-extension',
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should return UpdateAvailable for a local extension with updates', async () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'local-extension',
version: '1.1.0',
});
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'local-extension',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
});
it('should return Error when git check fails', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'error-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'error-extension',
state: ExtensionUpdateState.ERROR,
},
});
});
});
});
@@ -5,7 +5,32 @@
*/
import { expect, describe, it } from 'vitest';
import { hydrateString } from './variables.js';
import {
hydrateString,
recursivelyHydrateStrings,
validateVariables,
type VariableContext,
} from './variables.js';
describe('validateVariables', () => {
it('should not throw if all required variables are present', () => {
const schema = {
extensionPath: { type: 'string', description: 'test', required: true },
} as const;
const context = { extensionPath: 'value' };
expect(() => validateVariables(context, schema)).not.toThrow();
});
it('should throw if a required variable is missing', () => {
const schema = {
extensionPath: { type: 'string', description: 'test', required: true },
} as const;
const context = {};
expect(() => validateVariables(context, schema)).toThrow(
'Missing required variable: extensionPath',
);
});
});
describe('hydrateString', () => {
it('should replace a single variable', () => {
@@ -15,4 +40,88 @@ describe('hydrateString', () => {
const result = hydrateString('Hello, ${extensionPath}!', context);
expect(result).toBe('Hello, path/my-extension!');
});
it('should replace multiple variables', () => {
const context = {
extensionPath: 'path/my-extension',
workspacePath: '/ws',
};
const result = hydrateString(
'Ext: ${extensionPath}, WS: ${workspacePath}',
context,
);
expect(result).toBe('Ext: path/my-extension, WS: /ws');
});
it('should ignore unknown variables', () => {
const context = {
extensionPath: 'path/my-extension',
};
const result = hydrateString('Hello, ${unknown}!', context);
expect(result).toBe('Hello, ${unknown}!');
});
it('should handle null and undefined context values', () => {
const context: VariableContext = {
extensionPath: undefined,
};
const result = hydrateString(
'Ext: ${extensionPath}, WS: ${workspacePath}',
context,
);
expect(result).toBe('Ext: ${extensionPath}, WS: ${workspacePath}');
});
});
describe('recursivelyHydrateStrings', () => {
const context = {
extensionPath: 'path/my-extension',
workspacePath: '/ws',
};
it('should hydrate strings in a flat object', () => {
const obj = {
a: 'Hello, ${workspacePath}',
b: 'Hi, ${extensionPath}',
};
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual({
a: 'Hello, /ws',
b: 'Hi, path/my-extension',
});
});
it('should hydrate strings in an array', () => {
const arr = ['${workspacePath}', '${extensionPath}'];
const result = recursivelyHydrateStrings(arr, context);
expect(result).toEqual(['/ws', 'path/my-extension']);
});
it('should hydrate strings in a nested object', () => {
const obj = {
a: 'Hello, ${workspacePath}',
b: {
c: 'Hi, ${extensionPath}',
d: ['${workspacePath}/foo'],
},
};
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual({
a: 'Hello, /ws',
b: {
c: 'Hi, path/my-extension',
d: ['/ws/foo'],
},
});
});
it('should not modify non-string values', () => {
const obj = {
a: 123,
b: true,
c: null,
};
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual(obj);
});
});
+38 -1
View File
@@ -6,7 +6,12 @@
import { describe, it, expect } from 'vitest';
import type { KeyBindingConfig } from './keyBindings.js';
import { Command, defaultKeyBindings } from './keyBindings.js';
import {
Command,
commandCategories,
commandDescriptions,
defaultKeyBindings,
} from './keyBindings.js';
describe('keyBindings config', () => {
describe('defaultKeyBindings', () => {
@@ -16,6 +21,7 @@ describe('keyBindings config', () => {
for (const command of commands) {
expect(defaultKeyBindings[command]).toBeDefined();
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
}
});
@@ -78,4 +84,35 @@ describe('keyBindings config', () => {
expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' });
});
});
describe('command metadata', () => {
const commandValues = Object.values(Command);
it('has a description entry for every command', () => {
const describedCommands = Object.keys(commandDescriptions);
expect(describedCommands.sort()).toEqual([...commandValues].sort());
for (const command of commandValues) {
expect(typeof commandDescriptions[command]).toBe('string');
expect(commandDescriptions[command]?.trim()).not.toHaveLength(0);
}
});
it('categorizes each command exactly once', () => {
const seen = new Set<Command>();
for (const category of commandCategories) {
expect(typeof category.title).toBe('string');
expect(Array.isArray(category.commands)).toBe(true);
for (const command of category.commands) {
expect(commandValues).toContain(command);
expect(seen.has(command)).toBe(false);
seen.add(command);
}
}
expect(seen.size).toBe(commandValues.length);
});
});
});
+167 -2
View File
@@ -25,6 +25,14 @@ export enum Command {
// Screen control
CLEAR_SCREEN = 'clearScreen',
// Scrolling
SCROLL_UP = 'scrollUp',
SCROLL_DOWN = 'scrollDown',
SCROLL_HOME = 'scrollHome',
SCROLL_END = 'scrollEnd',
PAGE_UP = 'pageUp',
PAGE_DOWN = 'pageDown',
// History navigation
HISTORY_UP = 'historyUp',
HISTORY_DOWN = 'historyDown',
@@ -46,7 +54,7 @@ export enum Command {
// External tools
OPEN_EXTERNAL_EDITOR = 'openExternalEditor',
PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage',
PASTE_CLIPBOARD = 'pasteClipboard',
// App level bindings
SHOW_ERROR_DETAILS = 'showErrorDetails',
@@ -120,6 +128,14 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Screen control
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
// Scrolling
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
[Command.SCROLL_HOME]: [{ key: 'home' }],
[Command.SCROLL_END]: [{ key: 'end' }],
[Command.PAGE_UP]: [{ key: 'pageup' }],
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
// History navigation
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
@@ -176,7 +192,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'x', ctrl: true },
{ sequence: '\x18', ctrl: true },
],
[Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }],
[Command.PASTE_CLIPBOARD]: [{ key: 'v', ctrl: true }],
// App level bindings
[Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
@@ -199,3 +215,152 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
};
interface CommandCategory {
readonly title: string;
readonly commands: readonly Command[];
}
/**
* Presentation metadata for grouping commands in documentation or UI.
*/
export const commandCategories: readonly CommandCategory[] = [
{
title: 'Basic Controls',
commands: [Command.RETURN, Command.ESCAPE],
},
{
title: 'Cursor Movement',
commands: [Command.HOME, Command.END],
},
{
title: 'Editing',
commands: [
Command.KILL_LINE_RIGHT,
Command.KILL_LINE_LEFT,
Command.CLEAR_INPUT,
Command.DELETE_WORD_BACKWARD,
],
},
{
title: 'Screen Control',
commands: [Command.CLEAR_SCREEN],
},
{
title: 'Scrolling',
commands: [
Command.SCROLL_UP,
Command.SCROLL_DOWN,
Command.SCROLL_HOME,
Command.SCROLL_END,
Command.PAGE_UP,
Command.PAGE_DOWN,
],
},
{
title: 'History & Search',
commands: [
Command.HISTORY_UP,
Command.HISTORY_DOWN,
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
],
},
{
title: 'Navigation',
commands: [
Command.NAVIGATION_UP,
Command.NAVIGATION_DOWN,
Command.DIALOG_NAVIGATION_UP,
Command.DIALOG_NAVIGATION_DOWN,
],
},
{
title: 'Suggestions & Completions',
commands: [
Command.ACCEPT_SUGGESTION,
Command.COMPLETION_UP,
Command.COMPLETION_DOWN,
Command.EXPAND_SUGGESTION,
Command.COLLAPSE_SUGGESTION,
],
},
{
title: 'Text Input',
commands: [Command.SUBMIT, Command.NEWLINE],
},
{
title: 'External Tools',
commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD],
},
{
title: 'App Controls',
commands: [
Command.SHOW_ERROR_DETAILS,
Command.SHOW_FULL_TODOS,
Command.TOGGLE_IDE_CONTEXT_DETAIL,
Command.TOGGLE_MARKDOWN,
Command.TOGGLE_COPY_MODE,
Command.SHOW_MORE_LINES,
Command.TOGGLE_SHELL_INPUT_FOCUS,
],
},
{
title: 'Session Control',
commands: [Command.QUIT, Command.EXIT],
},
];
/**
* Human-readable descriptions for each command, used in docs/tooling.
*/
export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.RETURN]: 'Confirm the current selection or choice.',
[Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.',
[Command.HOME]: 'Move the cursor to the start of the line.',
[Command.END]: 'Move the cursor to the end of the line.',
[Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.',
[Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.',
[Command.CLEAR_INPUT]: 'Clear all text in the input field.',
[Command.DELETE_WORD_BACKWARD]: 'Delete the previous word.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.SCROLL_UP]: 'Scroll content up.',
[Command.SCROLL_DOWN]: 'Scroll content down.',
[Command.SCROLL_HOME]: 'Scroll to the top.',
[Command.SCROLL_END]: 'Scroll to the bottom.',
[Command.PAGE_UP]: 'Scroll up by one page.',
[Command.PAGE_DOWN]: 'Scroll down by one page.',
[Command.HISTORY_UP]: 'Show the previous entry in history.',
[Command.HISTORY_DOWN]: 'Show the next entry in history.',
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
[Command.NAVIGATION_DOWN]: 'Move selection down in lists.',
[Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.',
[Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.',
[Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.',
[Command.COMPLETION_UP]: 'Move to the previous completion option.',
[Command.COMPLETION_DOWN]: 'Move to the next completion option.',
[Command.SUBMIT]: 'Submit the current prompt.',
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt in an external editor.',
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
[Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.',
[Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.',
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: 'Toggle IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]:
'Toggle copy mode when the terminal is using the alternate buffer.',
[Command.QUIT]: 'Cancel the current request or quit the CLI.',
[Command.EXIT]: 'Exit the CLI when the input buffer is empty.',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines.',
[Command.REVERSE_SEARCH]: 'Start reverse search through history.',
[Command.SUBMIT_REVERSE_SEARCH]: 'Insert the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
[Command.TOGGLE_SHELL_INPUT_FOCUS]:
'Toggle focus between the shell and Gemini input.',
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
[Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.',
};
@@ -30,24 +30,24 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Allowed tool should be allowed
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
// Excluded tool should be denied
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
// Other write tools should ask user
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
// Unknown tools should use default
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should handle MCP server wildcard patterns correctly', async () => {
@@ -72,33 +72,49 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Tools from allowed server should be allowed
expect(engine.check({ name: 'allowed-server__tool1' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// Tools from allowed server should be allowed
expect(
engine.check({ name: 'allowed-server__another_tool' }, undefined),
(await engine.check({ name: 'allowed-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'allowed-server__another_tool' },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
// Tools from trusted server should be allowed
expect(engine.check({ name: 'trusted-server__tool1' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
engine.check({ name: 'trusted-server__special_tool' }, undefined),
(await engine.check({ name: 'trusted-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'trusted-server__special_tool' },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
// Tools from blocked server should be denied
expect(engine.check({ name: 'blocked-server__tool1' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
engine.check({ name: 'blocked-server__any_tool' }, undefined),
(await engine.check({ name: 'blocked-server__tool1' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__any_tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
// Tools from unknown servers should use default
expect(engine.check({ name: 'unknown-server__tool' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'unknown-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => {
@@ -118,12 +134,15 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// MCP server allowed (priority 2.1) provides general allow for server
expect(engine.check({ name: 'my-server__safe-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
// MCP server allowed (priority 2.1) provides general allow for server
expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// But specific tool exclude (priority 2.4) wins over server allow
expect(
engine.check({ name: 'my-server__dangerous-tool' }, undefined),
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
});
@@ -154,46 +173,50 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Read-only tools should be allowed (autoAccept)
expect(engine.check({ name: 'read_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'list_directory' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'list_directory' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
// But glob is explicitly excluded, so it should be denied
expect(engine.check({ name: 'glob' }, undefined)).toBe(
expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe(
PolicyDecision.DENY,
);
// Replace should ask user (normal write tool behavior)
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
// Explicitly allowed tools
expect(engine.check({ name: 'custom-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'my-server__special-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'custom-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// MCP server tools
expect(engine.check({ name: 'allowed-server__tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'trusted-server__tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'blocked-server__tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'allowed-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'trusted-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'blocked-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
// Write tools should ask by default
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should handle YOLO mode correctly', async () => {
@@ -210,20 +233,20 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Most tools should be allowed in YOLO mode
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
// But explicitly excluded tools should still be denied
expect(engine.check({ name: 'dangerous-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'dangerous-tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
});
it('should handle AUTO_EDIT mode correctly', async () => {
@@ -236,17 +259,17 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Edit tools should be allowed in AUTO_EDIT mode
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
// Other tools should follow normal rules
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should verify priority ordering works correctly in practice', async () => {
@@ -305,22 +328,24 @@ describe('Policy Engine Integration Tests', () => {
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
// Verify the engine applies these priorities correctly
expect(engine.check({ name: 'blocked-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'blocked-server__any' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'specific-tool' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'trusted-server__any' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'mcp-server__any' }, undefined)).toBe(
PolicyDecision.ALLOW,
);
expect(engine.check({ name: 'glob' }, undefined)).toBe(
expect(
(await engine.check({ name: 'blocked-tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__any' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'specific-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'trusted-server__any' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'mcp-server__any' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe(
PolicyDecision.ALLOW,
);
});
@@ -346,9 +371,10 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Exclusion (195) should win over trust (90)
expect(engine.check({ name: 'conflicted-server__tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'conflicted-server__tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
});
it('should handle edge case: specific tool allowed but server excluded', async () => {
@@ -369,12 +395,14 @@ describe('Policy Engine Integration Tests', () => {
// Server exclusion (195) wins over specific tool allow (100)
// This might be counterintuitive but follows the priority system
expect(engine.check({ name: 'my-server__special-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'my-server__other-tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'my-server__other-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
});
it('should verify non-interactive mode transformation', async () => {
@@ -389,12 +417,12 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(engineConfig);
// ASK_USER should become DENY in non-interactive mode
expect(engine.check({ name: 'unknown_tool' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(engine.check({ name: 'run_shell_command' }, undefined)).toBe(
PolicyDecision.DENY,
);
expect(
(await engine.check({ name: 'unknown_tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'run_shell_command' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
});
it('should handle empty settings gracefully', async () => {
@@ -407,17 +435,17 @@ describe('Policy Engine Integration Tests', () => {
const engine = new PolicyEngine(config);
// Should have default rules for write tools
expect(engine.check({ name: 'write_file' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(engine.check({ name: 'replace' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'write_file' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
expect(
(await engine.check({ name: 'replace' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
// Unknown tools should use default
expect(engine.check({ name: 'unknown' }, undefined)).toBe(
PolicyDecision.ASK_USER,
);
expect(
(await engine.check({ name: 'unknown' }, undefined)).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should verify rules are created with correct priorities', async () => {
@@ -0,0 +1,228 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { getPackageJson } from '@google/gemini-cli-core';
import commandExists from 'command-exists';
import * as os from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadSandboxConfig } from './sandboxConfig.js';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
getPackageJson: vi.fn(),
FatalSandboxError: class extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalSandboxError';
}
},
};
});
vi.mock('command-exists', () => ({
default: {
sync: vi.fn(),
},
}));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
platform: vi.fn(),
};
});
const mockedGetPackageJson = vi.mocked(getPackageJson);
const mockedCommandExistsSync = vi.mocked(commandExists.sync);
const mockedOsPlatform = vi.mocked(os.platform);
describe('loadSandboxConfig', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
process.env = { ...originalEnv };
mockedGetPackageJson.mockResolvedValue({
config: { sandboxImageUri: 'default/image' },
});
});
afterEach(() => {
process.env = originalEnv;
});
it('should return undefined if sandbox is explicitly disabled via argv', async () => {
const config = await loadSandboxConfig({}, { sandbox: false });
expect(config).toBeUndefined();
});
it('should return undefined if sandbox is explicitly disabled via settings', async () => {
const config = await loadSandboxConfig({ tools: { sandbox: false } }, {});
expect(config).toBeUndefined();
});
it('should return undefined if sandbox is not configured', async () => {
const config = await loadSandboxConfig({}, {});
expect(config).toBeUndefined();
});
it('should return undefined if already inside a sandbox (SANDBOX env var is set)', async () => {
process.env['SANDBOX'] = '1';
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toBeUndefined();
});
describe('with GEMINI_SANDBOX environment variable', () => {
it('should use docker if GEMINI_SANDBOX=docker and it exists', async () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
it('should throw if GEMINI_SANDBOX is an invalid command', async () => {
process.env['GEMINI_SANDBOX'] = 'invalid-command';
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec",
);
});
it('should throw if GEMINI_SANDBOX command does not exist', async () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(false);
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
"Missing sandbox command 'docker' (from GEMINI_SANDBOX)",
);
});
});
describe('with sandbox: true', () => {
it('should use sandbox-exec on darwin if available', async () => {
mockedOsPlatform.mockReturnValue('darwin');
mockedCommandExistsSync.mockImplementation(
(cmd) => cmd === 'sandbox-exec',
);
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({
command: 'sandbox-exec',
image: 'default/image',
});
});
it('should prefer sandbox-exec over docker on darwin', async () => {
mockedOsPlatform.mockReturnValue('darwin');
mockedCommandExistsSync.mockReturnValue(true); // all commands exist
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({
command: 'sandbox-exec',
image: 'default/image',
});
});
it('should use docker if available and sandbox is true', async () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker');
const config = await loadSandboxConfig({ tools: { sandbox: true } }, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
});
it('should use podman if available and docker is not', async () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman');
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({ command: 'podman', image: 'default/image' });
});
it('should throw if sandbox: true but no command is found', async () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockReturnValue(false);
await expect(loadSandboxConfig({}, { sandbox: true })).rejects.toThrow(
'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
'install docker or podman or specify command in GEMINI_SANDBOX',
);
});
});
describe("with sandbox: 'command'", () => {
it('should use the specified command if it exists', async () => {
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, { sandbox: 'podman' });
expect(config).toEqual({ command: 'podman', image: 'default/image' });
expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman');
});
it('should throw if the specified command does not exist', async () => {
mockedCommandExistsSync.mockReturnValue(false);
await expect(
loadSandboxConfig({}, { sandbox: 'podman' }),
).rejects.toThrow(
"Missing sandbox command 'podman' (from GEMINI_SANDBOX)",
);
});
it('should throw if the specified command is invalid', async () => {
await expect(
loadSandboxConfig({}, { sandbox: 'invalid-command' }),
).rejects.toThrow(
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec",
);
});
});
describe('image configuration', () => {
it('should use image from GEMINI_SANDBOX_IMAGE env var if set', async () => {
process.env['GEMINI_SANDBOX_IMAGE'] = 'env/image';
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'env/image' });
});
it('should use image from package.json if env var is not set', async () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
});
it('should return undefined if command is found but no image is configured', async () => {
mockedGetPackageJson.mockResolvedValue({}); // no sandboxImageUri
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toBeUndefined();
});
});
describe('truthy/falsy sandbox values', () => {
beforeEach(() => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker');
});
it.each([true, 'true', '1'])(
'should enable sandbox for value: %s',
async (value) => {
const config = await loadSandboxConfig({}, { sandbox: value });
expect(config).toEqual({ command: 'docker', image: 'default/image' });
},
);
it.each([false, 'false', '0', undefined, null, ''])(
'should disable sandbox for value: %s',
async (value) => {
// \`null\` is not a valid type for the arg, but good to test falsiness
const config = await loadSandboxConfig({}, { sandbox: value });
expect(config).toBeUndefined();
},
);
});
});
+2 -2
View File
@@ -21,7 +21,7 @@ const __dirname = path.dirname(__filename);
// This is a stripped-down version of the CliArgs interface from config.ts
// to avoid circular dependencies.
interface SandboxCliArgs {
sandbox?: boolean | string;
sandbox?: boolean | string | null;
}
const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'docker',
@@ -34,7 +34,7 @@ function isSandboxCommand(value: string): value is SandboxConfig['command'] {
}
function getSandboxCommand(
sandbox?: boolean | string,
sandbox?: boolean | string | null,
): SandboxConfig['command'] | '' {
// If the SANDBOX env var is set, we're already inside the sandbox.
if (process.env['SANDBOX']) {
@@ -0,0 +1,26 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { SettingPaths } from './settingPaths.js';
describe('SettingPaths', () => {
it('should have the correct structure', () => {
expect(SettingPaths).toEqual({
General: {
PreferredEditor: 'general.preferredEditor',
},
});
});
it('should be immutable', () => {
expect(Object.isFrozen(SettingPaths)).toBe(false); // It's not frozen by default in JS unless Object.freeze is called, but it's `as const` in TS.
// However, we can check if the values are correct.
expect(SettingPaths.General.PreferredEditor).toBe(
'general.preferredEditor',
);
});
});
+11
View File
@@ -0,0 +1,11 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const SettingPaths = {
General: {
PreferredEditor: 'general.preferredEditor',
},
} as const;
+204 -324
View File
@@ -157,107 +157,53 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({});
});
it('should load system settings if only system file exists', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === getSystemSettingsPath(),
);
const systemSettingsContent = {
ui: {
theme: 'system-default',
it.each([
{
scope: 'system',
path: getSystemSettingsPath(),
content: {
ui: { theme: 'system-default' },
tools: { sandbox: false },
},
tools: {
sandbox: false,
},
{
scope: 'user',
path: USER_SETTINGS_PATH,
content: {
ui: { theme: 'dark' },
context: { fileName: 'USER_CONTEXT.md' },
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
return '{}';
},
{
scope: 'workspace',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: {
tools: { sandbox: true },
context: { fileName: 'WORKSPACE_CONTEXT.md' },
},
);
},
])(
'should load $scope settings if only $scope file exists',
({ scope, path, content }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
getSystemSettingsPath(),
'utf-8',
);
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({
...systemSettingsContent,
});
});
it('should load user settings if only user file exists', () => {
const expectedUserSettingsPath = USER_SETTINGS_PATH; // Use the path actually resolved by the (mocked) module
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === expectedUserSettingsPath,
);
const userSettingsContent = {
ui: {
theme: 'dark',
},
context: {
fileName: 'USER_CONTEXT.md',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === expectedUserSettingsPath)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
expectedUserSettingsPath,
'utf-8',
);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({
...userSettingsContent,
});
});
it('should load workspace settings if only workspace file exists', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
tools: {
sandbox: true,
},
context: {
fileName: 'WORKSPACE_CONTEXT.md',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
MOCK_WORKSPACE_SETTINGS_PATH,
'utf-8',
);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual({
...workspaceSettingsContent,
});
});
expect(fs.readFileSync).toHaveBeenCalledWith(path, 'utf-8');
expect(
settings[scope as 'system' | 'user' | 'workspace'].settings,
).toEqual(content);
expect(settings.merged).toEqual(content);
},
);
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
(mockFsExistsSync as Mock).mockImplementation(
@@ -662,88 +608,63 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used
});
it('should handle contextFileName correctly when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = { context: { fileName: 'CUSTOM.md' } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '';
it.each([
{
description: 'contextFileName in user settings',
path: USER_SETTINGS_PATH,
content: { context: { fileName: 'CUSTOM.md' } },
expected: { key: 'context.fileName', value: 'CUSTOM.md' },
},
{
description: 'contextFileName in workspace settings',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: { context: { fileName: 'PROJECT_SPECIFIC.md' } },
expected: { key: 'context.fileName', value: 'PROJECT_SPECIFIC.md' },
},
{
description: 'excludedProjectEnvVars in user settings',
path: USER_SETTINGS_PATH,
content: {
advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'] },
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.context?.fileName).toBe('CUSTOM.md');
});
it('should handle contextFileName correctly when only in workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
context: { fileName: 'PROJECT_SPECIFIC.md' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
expected: {
key: 'advanced.excludedEnvVars',
value: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'],
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.context?.fileName).toBe('PROJECT_SPECIFIC.md');
});
it('should handle excludedProjectEnvVars correctly when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
general: {},
advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'] },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '';
},
{
description: 'excludedProjectEnvVars in workspace settings',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: {
advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] },
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.advanced?.excludedEnvVars).toEqual([
'DEBUG',
'NODE_ENV',
'CUSTOM_VAR',
]);
});
it('should handle excludedProjectEnvVars correctly when only in workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
general: {},
advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
expected: {
key: 'advanced.excludedEnvVars',
value: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
},
);
},
])(
'should handle $description correctly',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.advanced?.excludedEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
});
const settings = loadSettings(MOCK_WORKSPACE_DIR);
const keys = expected.key.split('.');
let result: unknown = settings.merged;
for (const key of keys) {
result = (result as { [key: string]: unknown })[key];
}
expect(result).toEqual(expected.value);
},
);
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
(mockFsExistsSync as Mock).mockImplementation(
@@ -810,37 +731,35 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.context?.fileName).toBeUndefined();
});
it('should load telemetry setting from user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = { telemetry: { enabled: 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);
expect(settings.merged.telemetry?.enabled).toBe(true);
});
it('should load telemetry setting from workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = { telemetry: { enabled: false } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry?.enabled).toBe(false);
});
it.each([
{
scope: 'user',
path: USER_SETTINGS_PATH,
content: { telemetry: { enabled: true } },
expected: true,
},
{
scope: 'workspace',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: { telemetry: { enabled: false } },
expected: false,
},
])(
'should load telemetry setting from $scope settings',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry?.enabled).toBe(expected);
},
);
it('should prioritize workspace telemetry setting over user setting', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
@@ -932,63 +851,60 @@ describe('Settings Loading and Merging', () => {
});
});
it('should handle MCP servers when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
mcpServers: {
it.each([
{
scope: 'user',
path: USER_SETTINGS_PATH,
content: {
mcpServers: {
'user-only-server': {
command: 'user-only-command',
description: 'User only server',
},
},
},
expected: {
'user-only-server': {
command: 'user-only-command',
description: 'User only server',
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '';
},
{
scope: 'workspace',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: {
mcpServers: {
'workspace-only-server': {
command: 'workspace-only-command',
description: 'Workspace only server',
},
},
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.mcpServers).toEqual({
'user-only-server': {
command: 'user-only-command',
description: 'User only server',
},
});
});
it('should handle MCP servers when only in workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
mcpServers: {
expected: {
'workspace-only-server': {
command: 'workspace-only-command',
description: 'Workspace only server',
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
},
])(
'should handle MCP servers when only in $scope settings',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.mcpServers).toEqual({
'workspace-only-server': {
command: 'workspace-only-command',
description: 'Workspace only server',
},
});
});
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.mcpServers).toEqual(expected);
},
);
it('should have mcpServers as undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
@@ -1104,85 +1020,49 @@ describe('Settings Loading and Merging', () => {
});
});
it('should merge compressionThreshold settings, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
general: {},
model: { compressionThreshold: 0.5 },
};
const workspaceSettingsContent = {
general: {},
model: { compressionThreshold: 0.8 },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
describe('compressionThreshold settings', () => {
it.each([
{
description:
'should be taken from user settings if only present there',
userContent: { model: { compressionThreshold: 0.5 } },
workspaceContent: {},
expected: 0.5,
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.model?.compressionThreshold).toEqual(0.5);
expect(settings.workspace.settings.model?.compressionThreshold).toEqual(
0.8,
);
expect(settings.merged.model?.compressionThreshold).toEqual(0.8);
});
it('should merge output format settings, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
output: { format: 'text' },
};
const workspaceSettingsContent = {
output: { format: 'json' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
{
description:
'should be taken from workspace settings if only present there',
userContent: {},
workspaceContent: { model: { compressionThreshold: 0.8 } },
expected: 0.8,
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.output?.format).toBe('json');
});
it('should handle compressionThreshold when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
general: {},
model: { compressionThreshold: 0.5 },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
{
description:
'should prioritize workspace settings over user settings',
userContent: { model: { compressionThreshold: 0.5 } },
workspaceContent: { model: { compressionThreshold: 0.8 } },
expected: 0.8,
},
);
{
description: 'should be undefined if not in any settings file',
userContent: {},
workspaceContent: {},
expected: undefined,
},
])('$description', ({ userContent, workspaceContent, expected }) => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH) return JSON.stringify(userContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.compressionThreshold).toEqual(0.5);
});
it('should have model as undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
(fs.readFileSync as Mock).mockReturnValue('{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model).toBeUndefined();
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.compressionThreshold).toEqual(expected);
});
});
it('should use user compressionThreshold if workspace does not define it', () => {
+2 -1
View File
@@ -33,6 +33,7 @@ import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import type { ExtensionManager } from './extension-manager.js';
import { SettingPaths } from './settingPaths.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@@ -108,7 +109,7 @@ const MIGRATION_MAP: Record<string, string> = {
memoryImportFormat: 'context.importFormat',
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
model: 'model.name',
preferredEditor: 'general.preferredEditor',
preferredEditor: SettingPaths.General.PreferredEditor,
retryFetchErrors: 'general.retryFetchErrors',
sandbox: 'tools.sandbox',
selectedAuthType: 'security.auth.selectedType',
+31 -7
View File
@@ -322,19 +322,43 @@ describe('SettingsSchema', () => {
).toBe('Enable debug logging of keystrokes to the console.');
});
it('should have useModelRouter setting in schema', () => {
it('should have previewFeatures setting in schema', () => {
expect(
getSettingsSchema().experimental.properties.useModelRouter,
getSettingsSchema().general.properties.previewFeatures,
).toBeDefined();
expect(getSettingsSchema().general.properties.previewFeatures.type).toBe(
'boolean',
);
expect(
getSettingsSchema().experimental.properties.useModelRouter.type,
).toBe('boolean');
getSettingsSchema().general.properties.previewFeatures.category,
).toBe('General');
expect(
getSettingsSchema().experimental.properties.useModelRouter.category,
).toBe('Experimental');
getSettingsSchema().general.properties.previewFeatures.default,
).toBe(false);
expect(
getSettingsSchema().experimental.properties.useModelRouter.default,
getSettingsSchema().general.properties.previewFeatures.requiresRestart,
).toBe(false);
expect(
getSettingsSchema().general.properties.previewFeatures.showInDialog,
).toBe(true);
expect(
getSettingsSchema().general.properties.previewFeatures.description,
).toBe('Enable preview features (e.g., preview models).');
});
it('should have isModelAvailabilityServiceEnabled setting in schema', () => {
const setting =
getSettingsSchema().experimental.properties
.isModelAvailabilityServiceEnabled;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(false);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(false);
expect(setting.description).toBe(
'Enable model routing using new availability service.',
);
});
});
+49 -11
View File
@@ -160,6 +160,15 @@ const SETTINGS_SCHEMA = {
description: 'General application settings.',
showInDialog: false,
properties: {
previewFeatures: {
type: 'boolean',
label: 'Preview Features (e.g., models)',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable preview features (e.g., preview models).',
showInDialog: true,
},
preferredEditor: {
type: 'string',
label: 'Preferred Editor',
@@ -251,6 +260,7 @@ const SETTINGS_SCHEMA = {
category: 'General',
requiresRestart: false,
default: undefined as SessionRetentionSettings | undefined,
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
@@ -470,7 +480,7 @@ const SETTINGS_SCHEMA = {
label: 'Show Line Numbers',
category: 'UI',
requiresRestart: false,
default: false,
default: true,
description: 'Show line numbers in the chat.',
showInDialog: true,
},
@@ -483,6 +493,15 @@ const SETTINGS_SCHEMA = {
description: 'Show citations for generated text in the chat.',
showInDialog: true,
},
showModelInfoInChat: {
type: 'boolean',
label: 'Show Model Info In Chat',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Show the model name in the chat for each model turn.',
showInDialog: true,
},
useFullWidth: {
type: 'boolean',
label: 'Use Full Width',
@@ -502,6 +521,16 @@ const SETTINGS_SCHEMA = {
'Use an alternate screen buffer for the UI, preserving shell history.',
showInDialog: true,
},
incrementalRendering: {
type: 'boolean',
label: 'Incremental Rendering',
category: 'UI',
requiresRestart: true,
default: true,
description:
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
showInDialog: true,
},
customWittyPhrases: {
type: 'array',
label: 'Custom Witty Phrases',
@@ -664,7 +693,7 @@ const SETTINGS_SCHEMA = {
label: 'Compression Threshold',
category: 'Model',
requiresRestart: true,
default: 0.2 as number,
default: 0.5 as number,
description:
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true,
@@ -700,6 +729,16 @@ const SETTINGS_SCHEMA = {
'Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.',
showInDialog: false,
},
customAliases: {
type: 'object',
label: 'Custom Model Config Aliases',
category: 'Model',
requiresRestart: false,
default: {},
description:
'Custom named presets for model configs. These are merged with (and override) the built-in aliases.',
showInDialog: false,
},
overrides: {
type: 'array',
label: 'Model Config Overrides',
@@ -1077,11 +1116,11 @@ const SETTINGS_SCHEMA = {
},
useWriteTodos: {
type: 'boolean',
label: 'Use Write Todos',
label: 'Use WriteTodos',
category: 'Advanced',
requiresRestart: false,
default: false,
description: 'Enable the write_todos_list tool.',
default: true,
description: 'Enable the write_todos tool.',
showInDialog: false,
},
security: {
@@ -1252,15 +1291,14 @@ const SETTINGS_SCHEMA = {
'Enables extension loading/unloading within the CLI session.',
showInDialog: false,
},
useModelRouter: {
isModelAvailabilityServiceEnabled: {
type: 'boolean',
label: 'Use Model Router',
label: 'Enable Model Availability Service',
category: 'Experimental',
requiresRestart: true,
default: true,
description:
'Enable model routing to route requests to the best model based on complexity.',
showInDialog: true,
default: false,
description: 'Enable model routing using new availability service.',
showInDialog: false,
},
codebaseInvestigatorSettings: {
type: 'object',

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