Compare commits

...

321 Commits

Author SHA1 Message Date
github-actions[bot]
28876bb4c6 chore(release): v0.0.8-nightly.3 2025-08-19 00:13:46 +00:00
Fan
7b378e826c feat: project/global save location option (#368) 2025-08-18 23:09:50 +08:00
thuan1412
5e70b34041 feat: use .geminiignore in grep tool (#349)
* feat: use .geminiignore in grep tool
2025-08-18 11:37:26 +08:00
tanzhenxin
df1479f864 Chore/release 0.0.7 (#343)
* chore: pump version to 0.0.7 and add changelog.md
2025-08-15 18:49:13 +08:00
Mingholy
14e6d3c01e Update qwen-code-pr-review.yml
Trigger Qwen PR Review when a PR opens.
Fix the auto-skip issue.
2025-08-15 18:24:43 +08:00
pomelo
da0b8b5534 Merge pull request #340 from QwenLM/feat/web_fetch_tool
feat: refactor web-fetch tool to remove google genai dependency
2025-08-15 18:10:32 +08:00
tanzhenxin
e1d502991d chore: remove https restricton 2025-08-15 17:58:05 +08:00
tanzhenxin
7e01554b9c chore: fix test case failure 2025-08-15 17:27:09 +08:00
tanzhenxin
36c65658ff chore: npm run lint 2025-08-15 17:16:05 +08:00
tanzhenxin
a925ac56fa Potential fix for code scanning alert no. 24: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-15 17:10:20 +08:00
tanzhenxin
5d4a9452d8 feat: refactor web-fetch tool to remove google genai dependency 2025-08-15 17:06:00 +08:00
tanzhenxin
3e082ae89a feat: replace google web search with tavily web search (#329) 2025-08-14 21:20:23 +08:00
Fan
51207043d0 fix: custom API's trailing space and empty tool id issues (#326)
* fix: generate random tool call id when serving API does not have one

* tmp
2025-08-14 21:18:52 +08:00
Mingholy
2403061bab fix: OpenAI tools (#328)
- MCP tool params schema lost causing all MCP not working well
- Compatible with occasional llm return tool call parameters that are invalid json
2025-08-14 21:18:26 +08:00
Mingholy
1ffcb51052 fix: seperate static QR code and dynamic spin components (#327)
* fix: seperate static QR code and dynamic spin components

* fix: format issues
2025-08-14 21:17:56 +08:00
tanzhenxin
c33d162ff2 Merge pull request #325 from QwenLM/fix/max_listeners_warning
fix: qwen logger exit handler setup
2025-08-14 20:06:14 +08:00
tanzhenxin
bbfe94cfe2 chore: npm run format 2025-08-14 18:54:24 +08:00
pomelo
03c7b1836f Merge pull request #323 from QwenLM/release/0.0.6
chore: bump version to 0.0.6
2025-08-14 18:54:04 +08:00
pomelo
f2ba6dbb8a Merge pull request #322 from QwenLM/fix/concurrent_requests
feat: prevent concurrent query submissions in useGeminiStream hook
2025-08-14 18:53:19 +08:00
tanzhenxin
2d0884b04d fix: qwen logger exit handler setup 2025-08-14 18:08:14 +08:00
tanzhenxin
fc70439355 chore: bump version to 0.0.6 2025-08-14 16:52:39 +08:00
tanzhenxin
0265b67b90 chore: npm run format & lint 2025-08-14 16:48:51 +08:00
tanzhenxin
1f91b9ece1 Merge pull request #309 from QwenLM/chore/sync-gemini-cli-v0.1.18
Sync with upstream gemini-cli v0.1.18
2025-08-14 16:47:31 +08:00
tanzhenxin
c58106079e feat: prevent concurrent query submissions in useGeminiStream hook 2025-08-14 16:39:26 +08:00
pomelo
6516d0d136 Merge pull request #313 from QwenLM/chore/log_api_request
chore: add api request logger
2025-08-13 18:54:02 +08:00
tanzhenxin
5369af61d2 chore: add api request logger 2025-08-13 18:51:40 +08:00
pomelo
6a4005cace Merge pull request #262 from nguu0123/main
feat(sandbox): add GHA to build sandbox image
2025-08-13 18:37:39 +08:00
tanzhenxin
290ccdbe21 chore: stick openai sdk version to 5.11.0 2025-08-13 16:10:04 +08:00
tanzhenxin
b5514fd052 chore: fix invalid package deps 2025-08-13 16:00:26 +08:00
tanzhenxin
bc92da04e9 Merge tag 'v0.1.18' of https://github.com/google-gemini/gemini-cli into chore/sync-gemini-cli-v0.1.18 2025-08-13 15:11:10 +08:00
tanzhenxin
0bc45aeefe chore: build issue, fallback to 0.0.5 version 2025-08-12 21:58:12 +08:00
tanzhenxin
7856f52afb Merge pull request #298 from QwenLM/chore/pkg_version
Chore/pkg version
2025-08-12 21:12:07 +08:00
tanzhenxin
e986476fe0 chore: pump version to 0.0.6 2025-08-12 21:03:25 +08:00
tanzhenxin
cfc1aebee6 chore: use correct CLI_VERSION for logging 2025-08-12 21:00:17 +08:00
pomelo
ef1c8a4bfe Merge pull request #293 from Clarence-pan/main
fix: 🐛 fix EPERM error when run `qwen --sandbox` in macOS
2025-08-12 19:53:59 +08:00
tanzhenxin
484292b2ac Merge pull request #297 from QwenLM/chore/pr-review-action
chore: adjust workflow to run PR review
2025-08-12 19:35:07 +08:00
mingholy.lmh
f9659184d4 chore: adjust workflow to run PR review 2025-08-12 18:05:52 +08:00
tanzhenxin
6d5bb1b57c Merge pull request #284 from QwenLM/feat/usage_stats_logging
feat: add usage statistics logging for Qwen integration
2025-08-12 17:56:34 +08:00
tanzhenxin
fb9f2d292c Merge pull request #274 from QwenLM/feat/memory_tool_docs
Make `/init` respect configured context filename and align docs with QWEN.md
2025-08-12 17:56:16 +08:00
Clarence-pan
16ea8560b7 fix: 🐛 fix EPERM error when run qwen --sandbox in macOS 2025-08-12 15:04:01 +08:00
tanzhenxin
2655af079a chore: npm run format & lint 2025-08-12 12:21:39 +08:00
tanzhenxin
807844fb57 feat: implement usage stats logging with telemetry refactoring 2025-08-12 12:16:38 +08:00
mingholy.lmh
2202d26ac7 fix: add comment 2025-08-12 11:58:58 +08:00
mingholy.lmh
58f66ccfc6 fix: openaiContentGenerator
- remove `metadata` when using unspported models/providers
- use `qwen3-code-plus` as default, fix picking wrong model when refresh auth
2025-08-12 11:58:58 +08:00
mingholy.lmh
65c622c0ac test: tweak test cases 2025-08-12 11:58:41 +08:00
mingholy.lmh
a3ec2f52c9 fix: terminal flicker when waiting for login 2025-08-12 11:58:41 +08:00
tanzhenxin
c96852dc56 feat: add usage statistics logging for Qwen integration 2025-08-11 22:13:56 +08:00
tanzhenxin
028a82ebeb chore: format&lint 2025-08-11 12:01:43 +08:00
tanzhenxin
6b67cd1b57 make /init respect configured context filename and align docs with QWEN.md 2025-08-11 11:56:12 +08:00
tanzhenxin
96a9b683b2 Merge pull request #266 from mahone3297/fix-readme-status-command
Fix README.md: Replace /status command with /stats command in documen…
2025-08-11 09:54:03 +08:00
tanzhenxin
dcc86699cf Merge pull request #235 from AstroAir/main
rename GEMINI.md to QWEN.md across the codebase
2025-08-11 09:51:01 +08:00
mahone3297
964509f587 Fix README.md: Replace /status command with /stats command in documentation
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-10 21:54:26 +08:00
nguu0123
a37423bf7f Update name of the workflow 2025-08-10 13:40:24 +03:00
nguu0123
bfcb3e7f1d Remove redundant Node.js setup and build steps from Docker workflow 2025-08-10 13:23:49 +03:00
nguu0123
1a581ed191 Limit docker image workflow to tags and manual triggers 2025-08-10 13:17:34 +03:00
nguu0123
5c94913643 Refactor Dockerfile with multi-stage build for smaller image size 2025-08-10 13:00:46 +03:00
nguu0123
e221b077e5 Fix gha version 2025-08-10 12:26:51 +03:00
nguu0123
0f58b3fd32 fix qemu gha version 2025-08-10 12:24:18 +03:00
nguu0123
32d06b2fc1 Add publish image gha 2025-08-10 12:20:22 +03:00
AstroAir
e3a5806ae2 fix: simplify mock return values for QWEN.md in App tests 2025-08-10 10:19:47 +08:00
Max Qian
a45adbdc76 Merge branch 'QwenLM:main' into main 2025-08-10 08:36:39 +08:00
tanzhenxin
41500814b0 Merge pull request #242 from nipeharefa/rename-make-npx
fix: rename make run-npx from gemini to qwen
2025-08-09 22:26:14 +08:00
Nipe Setiawan Harefa
786832913b fix: rename make run-npx from gemini to qwen 2025-08-09 16:58:09 +07:00
AstroAir
4807434d9f refactor: rename GEMINI.md to QWEN.md across the codebase 2025-08-09 10:33:02 +08:00
pomelo
c09abb817f Merge pull request #227 from QwenLM/fix/remove-google-registry
chore: remove google registry
2025-08-08 20:51:42 +08:00
tanzhenxin
b7663950f2 chore: remove google registry 2025-08-08 20:45:54 +08:00
tanzhenxin
8158e82165 Merge pull request #225 from QwenLM/feat/qwen-oauth
feat(oauth): add Qwen OAuth integration
2025-08-08 17:51:31 +08:00
mingholy.lmh
f8d3571e31 fix: switch baseUrl to prod 2025-08-08 16:31:17 +08:00
tanzhenxin
6f399c078a chore: format 2025-08-08 15:02:17 +08:00
tanzhenxin
854c452580 Merge branch 'feat/qwen-oauth' of https://github.com/QwenLM/qwen-code into feat/qwen-oauth 2025-08-08 14:57:26 +08:00
tanzhenxin
f503be14e9 chore: add metadata on openai content generator 2025-08-08 14:57:13 +08:00
mingholy.lmh
5d2a678cb2 docs: update README for qwen-oauth 2025-08-08 14:08:08 +08:00
mingholy.lmh
ce632725b0 refactor: re-organize Qwen related code files.
Co-authored-by: tanzhenxin <tanzhenxing1987@gmail.com>
Co-authored-by: pomelo-nwu <czynwu@outlook.com>
2025-08-08 11:55:58 +08:00
mingholy.lmh
ea7dcf8347 feat(oauth): add Qwen OAuth integration 2025-08-08 10:30:18 +08:00
Fan
ffc2d27ca3 feat: add qwencoder as co-author (#207)
* init

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix shell tool regex pattern for git commit messages

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-07 17:07:56 +08:00
Yiheng Xu
f0c60b90ea Merge pull request #206 from feature/yiheng/sync-gemini-cli-0.1.17
sync gemini cli 0.1.17
2025-08-06 17:09:54 +08:00
Yiheng Xu
14a3be7976 fix generateJson with respond in schema
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-06 17:03:57 +08:00
github-actions[bot]
ea96293e16 chore(release): v0.1.18 2025-08-06 00:58:42 +00:00
Jerop Kipruto
cd7e60e008 switch from heads to tags in url path (#5638) 2025-08-05 17:47:28 -07:00
Sandy Tao
59bde4a612 fix(core) Fix not resetting when after first get out of completion suggestions (#5635)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-06 00:37:44 +00:00
Bryan Morgan
02f7e48c51 Removed GitHub Actions experiment files (#5627) 2025-08-06 00:01:18 +00:00
christine betts
aeb6602266 Remove a few witty loading phrases (#5631)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-05 23:59:14 +00:00
David Rees
805114aef8 fix(docs): Fix code block delimiters in commands.md (#5521)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 23:30:57 +00:00
Justin Mahood
91035ad7b0 Fix(vim): Fix shell mode in Vim mode (#5567)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 23:29:37 +00:00
Bryant Chandler
12a9bc3ed9 feat(core, cli): Introduce high-performance FileSearch engine (#5136)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 23:18:03 +00:00
Allen Hutchison
2141b39c3d feat(cli): route non-interactive output to stderr (#5624) 2025-08-05 23:11:21 +00:00
Shreya Keshive
268627469b Refactor IDE client state management, improve user-facing error messages, and add logging of connection events (#5591)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-05 22:52:58 +00:00
Jacob MacDonald
6a72cd064b check for the prompt capability before listing prompts from MCP servers (#5616)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:50:30 +00:00
sangwook
aebe3ace3c perf(core): implement parallel file processing for 74% performance improvement (#4763)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:47:18 +00:00
8bitmp3
c402784d97 Fix and improve Gemini CLI troubleshooting.md doc (#2734)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:43:41 +00:00
William Thurston
bed6ab1cce fix(start): use absolute path to resolve CLI package (#3196)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:43:15 +00:00
xyizko
1b08a6c063 fix(minor): Grammar and Typos (#5053)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-05 22:41:27 +00:00
Sandy Tao
82fa7a0660 fix(format) Fix format for .github/workflows/weekly-velocity-report.yml (#5622) 2025-08-05 22:32:06 +00:00
Bryan Morgan
2e9236fab4 Update weekly-velocity-report.yml 2025-08-05 18:11:06 -04:00
Mikhail Aksenov
dadf05809c feat: mcp - support audiences for OAuth2 (#5265) 2025-08-05 22:02:16 +00:00
Ramón Medrano Llamas
29c3825604 fix(mcp): clear prompt registry on refresh to prevent duplicates (#5385)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 21:59:31 +00:00
Hiroaki Mitsuyoshi
faf6a5497a feat(docs): Add /chat delete command in commands.md (#5408)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 21:58:09 +00:00
Jacob Richman
dd85aaa951 bug(core): Fix flaky test by using waitFor. (#5540)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 21:56:38 +00:00
Gal Zahavi
aacae1de43 fix(core): prevent UI shift after vim edit (#5315) 2025-08-05 21:55:54 +00:00
Sandy Tao
8d993156e7 Fix format (#5617) 2025-08-05 21:38:43 +00:00
Bryan Morgan
57003ca68c Update weekly-velocity-report.yml 2025-08-05 17:18:19 -04:00
Bryan Morgan
47de37eb0a Update weekly-velocity-report.yml 2025-08-05 17:10:37 -04:00
Bryan Morgan
dc7b4fda64 Update weekly-velocity-report.yml 2025-08-05 17:08:22 -04:00
Bryan Morgan
3dcca31796 Update weekly-velocity-report.yml 2025-08-05 17:00:44 -04:00
Bryan Morgan
c194a6ac3b GitHub Action for velocity reporting purposes (#5607) 2025-08-05 20:33:59 +00:00
Bryan Morgan
d421fa9e64 Testing basic velocity report action 2025-08-05 15:55:50 -04:00
Luccas Paroni
2778c7d851 feat(core): Parse Multimodal MCP Tool responses (#5529)
Co-authored-by: Luccas Paroni <luccasparoni@google.com>
2025-08-05 19:19:47 +00:00
Oleksandr Gotgelf
b465145229 chore(settings): clean up comments in settings.ts (#5576) 2025-08-05 19:10:16 +00:00
Alexander J
f2d6748432 fix: small typo in ROADMAP.md (#5593)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-05 19:04:10 +00:00
joshualitt
08f1431946 bug(core): fix contentRangeTruncated calculation. (#5329)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 18:52:39 +00:00
David East
43d5aaa798 fix(mcp): ensure authorization url is valid when containing query params (#5545)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 18:44:30 +00:00
Yuki Okita
5c8268b6f4 feat: Multi-Directory Workspace Support (part 3: configuration in settings.json) (#5354)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-05 17:01:01 +00:00
Jack Wotherspoon
d0cda58f1f docs: update typo in commands.md (#5584) 2025-08-05 14:03:58 +00:00
Yiheng Xu
9ffeacc0f9 fix tool
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-05 17:09:25 +08:00
Yiheng Xu
cd375fefe5 sync gemini-cli 0.1.17
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-05 17:09:19 +08:00
N. Taylor Mullen
c7a1de4983 chore(release): v0.1.17 (#5561) 2025-08-04 21:37:32 -07:00
DeWitt Clinton
49001a0f83 Remove the "local modifications" string from bug and about reports. (#5552) 2025-08-05 04:01:19 +00:00
Olcan
11ecf6fc86 fix self-reference in build script (#5548) 2025-08-05 01:12:21 +00:00
github-actions[bot]
42a0336876 chore(release): v0.1.17 2025-08-05 00:30:08 +00:00
Harold Mciver
99ba2f6424 Update MCP client to connect to servers with only prompts (#5290) 2025-08-04 21:38:23 +00:00
Harold Mciver
a7ea4ce0c8 Update MCP client to connect to servers with only prompts (#5290) 2025-08-04 21:38:23 +00:00
christine betts
93f8fe3671 [ide-mode] Add openDiff tool to IDE MCP server (#4519)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-04 21:36:23 +00:00
christine betts
d54780edda [ide-mode] Add openDiff tool to IDE MCP server (#4519)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-04 21:36:23 +00:00
Mo Moadeli
e7b468e122 feat(cli): Prevent redundant opening of browser tabs when zero MCP servers are configured (#5367)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-04 21:20:49 +00:00
Mo Moadeli
3562ab8f5c feat(cli): Prevent redundant opening of browser tabs when zero MCP servers are configured (#5367)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-04 21:20:49 +00:00
Shreya Keshive
dca040908a ide-mode flag cleanup (#5531) 2025-08-04 21:06:50 +00:00
Shreya Keshive
7b03d057ea ide-mode flag cleanup (#5531) 2025-08-04 21:06:50 +00:00
Shreya Keshive
2180dd13dc Improve user-facing error messages for IDE mode (#5522) 2025-08-04 21:06:17 +00:00
Shreya Keshive
0895e29c1b Improve user-facing error messages for IDE mode (#5522) 2025-08-04 21:06:17 +00:00
Richie Foreman
11808ef7ed fix(core): Allow model to be set from settings.json (#5527) 2025-08-04 20:41:58 +00:00
Richie Foreman
fb6d9cbd36 fix(core): Allow model to be set from settings.json (#5527) 2025-08-04 20:41:58 +00:00
Sandy Tao
8da6d23688 refactor(core): Rename useSlashCompletion to useCommandCompletion (#5532) 2025-08-04 20:35:26 +00:00
Sandy Tao
48fa6f84c8 refactor(core): Rename useSlashCompletion to useCommandCompletion (#5532) 2025-08-04 20:35:26 +00:00
Seth Vargo
37b83e05a7 Use new URLs for downloading workflows (#5524) 2025-08-04 20:10:36 +00:00
Seth Vargo
016a263409 Use new URLs for downloading workflows (#5524) 2025-08-04 20:10:36 +00:00
Jacob MacDonald
5caf23d627 remove unnecessary checks in WriteFileChecks.getDescription (#5526) 2025-08-04 19:12:33 +00:00
Jacob MacDonald
12fc17bc8c remove unnecessary checks in WriteFileChecks.getDescription (#5526) 2025-08-04 19:12:33 +00:00
Sandy Tao
d1bfba1abb feat(core): Add trailing space when completing an at completion suggestion (#5475)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-04 18:30:59 +00:00
Sandy Tao
8ba12269d5 feat(core): Add trailing space when completing an at completion suggestion (#5475)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-04 18:30:59 +00:00
Sandy Tao
b9fe4fc263 feat(cli): Handle Punctuation in @ Command Parsing (#5482) 2025-08-04 17:49:15 +00:00
Sandy Tao
02e44e5db2 feat(cli): Handle Punctuation in @ Command Parsing (#5482) 2025-08-04 17:49:15 +00:00
Pyush Sinha
e506b40c27 fix: /help remove flickering and respect clear shortcut (ctr+l) (#3611)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-04 16:53:50 +00:00
Pyush Sinha
ca19aa9125 fix: /help remove flickering and respect clear shortcut (ctr+l) (#3611)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-04 16:53:50 +00:00
owenofbrien
83a04c4755 Cloud Shell surface logging fix (#5364) 2025-08-04 16:48:46 +00:00
owenofbrien
16d29e2d6f Cloud Shell surface logging fix (#5364) 2025-08-04 16:48:46 +00:00
matt korwel
94b7b402c5 feat(docs): create new documentation for automation and triage (#5363) 2025-08-04 08:49:14 -07:00
matt korwel
cdbe26b811 feat(docs): create new documentation for automation and triage (#5363) 2025-08-04 08:49:14 -07:00
koalazf.99
f1146c4b2e fix: ci 2025-08-04 22:06:35 +08:00
koalazf.99
0af8b65407 test pr 2025-08-04 18:21:25 +08:00
koalazf.99
db1e358081 add: @qwen pr review 2025-08-04 17:58:01 +08:00
koalazf.99
a28bf81185 update: github workflow actions: pr triage 2025-08-04 17:35:06 +08:00
koalazf.99
d1964200f9 update: github workflow actions 2025-08-04 17:30:46 +08:00
koalazf.99
42ab185890 replace github token 2025-08-04 17:09:57 +08:00
koalazf.99
b2bff47fc7 update action version 2025-08-04 17:02:03 +08:00
koalazf.99
f1328b8437 fix: package dependency && issue traige 2025-08-04 16:20:24 +08:00
koalazf.99
54e41e3b31 skip create app token 2025-08-04 16:12:22 +08:00
koalazf.99
c306cd89fc skip create app token 2025-08-04 16:10:30 +08:00
koalazf.99
0414768cf8 try: github actions 2025-08-04 15:32:20 +08:00
Kumbham Ajay Goud
a8984a9b30 Fix: Preserve conversation history when changing auth methods via /auth (#5216)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-03 22:03:01 +00:00
Kumbham Ajay Goud
bdfff529aa Fix: Preserve conversation history when changing auth methods via /auth (#5216)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-03 22:03:01 +00:00
Ali Al Jufairi
acd48a1259 docs(fix): Update themes documentation to include new color keys for… (#5467) 2025-08-03 21:56:27 +00:00
Ali Al Jufairi
f83c6168ad docs(fix): Update themes documentation to include new color keys for… (#5467) 2025-08-03 21:56:27 +00:00
N. Taylor Mullen
70478b92a9 chore(release): v0.1.16 (#5478) 2025-08-03 13:38:03 -07:00
N. Taylor Mullen
c7d1a28ac6 chore(release): v0.1.16 (#5478) 2025-08-03 13:38:03 -07:00
Shreya Keshive
2cdaf912ba Generate NOTICES.TXT and surface via command (#5310) 2025-08-03 20:19:34 +00:00
Shreya Keshive
4f69b2d8dc Generate NOTICES.TXT and surface via command (#5310) 2025-08-03 20:19:34 +00:00
Ayesha Shafique
072d8ba289 feat: Add reverse search capability for shell commands (#4793) 2025-08-03 19:53:24 +00:00
Ayesha Shafique
0335ce5ecc feat: Add reverse search capability for shell commands (#4793) 2025-08-03 19:53:24 +00:00
Oleksandr Gotgelf
03ed37d0dc fix: exclude DEBUG and DEBUG_MODE from project .env files by default (#5289)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-03 18:44:15 +00:00
Oleksandr Gotgelf
c0b4fc9506 fix: exclude DEBUG and DEBUG_MODE from project .env files by default (#5289)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-03 18:44:15 +00:00
Billy Biggs
bedcbb9feb Add a setting to disable the version update nag message (#5449) 2025-08-03 18:20:55 +00:00
Billy Biggs
ae8a8f6062 Add a setting to disable the version update nag message (#5449) 2025-08-03 18:20:55 +00:00
奕桁
055c0d8374 version v0.0.4 2025-08-03 18:33:55 +08:00
Gal Zahavi
820169ba2e feat(autoupdate): Improve update check and refactor for testability (#5389) 2025-08-02 03:17:32 +00:00
Gal Zahavi
8d5fa18893 feat(autoupdate): Improve update check and refactor for testability (#5389) 2025-08-02 03:17:32 +00:00
TIRUMALASETTI PRANITH
15a1f1af9d fix(config): Resolve duplicate config loading from home directory (#5090)
Co-authored-by: Allen Hutchison <adh@google.com>
Co-authored-by: Allen Hutchison <allen@hutchison.org>
2025-08-01 22:22:17 +00:00
TIRUMALASETTI PRANITH
f50ec186b5 fix(config): Resolve duplicate config loading from home directory (#5090)
Co-authored-by: Allen Hutchison <adh@google.com>
Co-authored-by: Allen Hutchison <allen@hutchison.org>
2025-08-01 22:22:17 +00:00
Allen Hutchison
387706607d fix(tests): refactor integration tests to be less flaky (#4890)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-01 21:33:33 +00:00
Allen Hutchison
321e1e25c7 fix(tests): refactor integration tests to be less flaky (#4890)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-01 21:33:33 +00:00
mrcabbage972
dccca91fc9 Switch utility calls to use the gemini-2.5-flash-lite model (#5193)
Co-authored-by: Anjali Sridhar <anjsridhar@gmail.com>
2025-08-01 21:11:51 +00:00
mrcabbage972
82972e4b03 Switch utility calls to use the gemini-2.5-flash-lite model (#5193)
Co-authored-by: Anjali Sridhar <anjsridhar@gmail.com>
2025-08-01 21:11:51 +00:00
owenofbrien
a6a386f72a Propagate prompt (#5033) 2025-08-01 19:37:56 +00:00
owenofbrien
8484730cd6 Propagate prompt (#5033) 2025-08-01 19:37:56 +00:00
joshualitt
67d16992cf bug(cli): Prefer IPv4 dns resolution by default. (#5338) 2025-08-01 19:30:39 +00:00
joshualitt
e5ce7d4872 bug(cli): Prefer IPv4 dns resolution by default. (#5338) 2025-08-01 19:30:39 +00:00
Santhosh Kumar
9382334a5e feat(github): add workflow to manage stale issues and PRs (#4871)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-01 19:26:03 +00:00
Santhosh Kumar
786750b1b5 feat(github): add workflow to manage stale issues and PRs (#4871)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-01 19:26:03 +00:00
Sandy Tao
c795168e9c feat(core): Use completionStart/End for slash command auto-completion (#5374) 2025-08-01 18:51:38 +00:00
Sandy Tao
e7699ddfb1 feat(core): Use completionStart/End for slash command auto-completion (#5374) 2025-08-01 18:51:38 +00:00
Billy Biggs
24c5a15d7a Add a setting to disable auth mode validation on startup (#5358) 2025-08-01 18:49:03 +00:00
Billy Biggs
cab60a38a1 Add a setting to disable auth mode validation on startup (#5358) 2025-08-01 18:49:03 +00:00
andrea-berling
c725e258c6 feat(sandbox): Add SANDBOX_FLAGS for custom container options (#2036)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-01 16:32:44 +00:00
andrea-berling
a2db3d1b38 feat(sandbox): Add SANDBOX_FLAGS for custom container options (#2036)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-01 16:32:44 +00:00
Brian de Alwis
d42e3f1e7f doc: use standard Google security policy for GitHub projects (#5062) 2025-08-01 16:12:32 +00:00
Brian de Alwis
eafcfcd169 doc: use standard Google security policy for GitHub projects (#5062) 2025-08-01 16:12:32 +00:00
Silvio Junior
7748e56153 [Fix Telemetry for tool calls, PR 1/n] Propagate tool reported errors via ToolCallResponseInfo and ToolResult (#5222) 2025-08-01 15:20:08 +00:00
Silvio Junior
0d23195624 [Fix Telemetry for tool calls, PR 1/n] Propagate tool reported errors via ToolCallResponseInfo and ToolResult (#5222) 2025-08-01 15:20:08 +00:00
cornmander
e126d2fcd9 Add missing emacs entry in UI. (#5351) 2025-08-01 14:40:05 +00:00
cornmander
138e52b61e Add missing emacs entry in UI. (#5351) 2025-08-01 14:40:05 +00:00
Brian Ray
dc9f17bb4a New browser launcher for MCP OAuth. (#5261) 2025-08-01 05:47:22 +00:00
Brian Ray
78435ab0bf New browser launcher for MCP OAuth. (#5261) 2025-08-01 05:47:22 +00:00
Sandy Tao
f21ff09389 fix(core): Remove json output schema form the next speaker check prompt (#5325)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-01 01:17:52 +00:00
Sandy Tao
ef445212f6 fix(core): Remove json output schema form the next speaker check prompt (#5325)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-01 01:17:52 +00:00
Raushan Raj
6c3fb18ef6 Update gemini-automated-issue-triage.yml (#5312)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-01 01:14:26 +00:00
Raushan Raj
c1157352b7 Update gemini-automated-issue-triage.yml (#5312)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-01 01:14:26 +00:00
Tommaso Sciortino
a3a432e3cf Fix bug executing commands in windows whose flags contain spaces (#5317) 2025-08-01 00:27:07 +00:00
Tommaso Sciortino
9397336a15 Fix bug executing commands in windows whose flags contain spaces (#5317) 2025-08-01 00:27:07 +00:00
Miguel Solorio
6f7beb414c Highlight slash commands in history (#5323) 2025-07-31 23:24:23 +00:00
Miguel Solorio
8e6c715b0f Highlight slash commands in history (#5323) 2025-07-31 23:24:23 +00:00
Jacob Richman
61e382444a fix(ux) bug in replaceRange dealing with newLines that was breaking vim support (#5320) 2025-07-31 23:16:29 +00:00
Jacob Richman
750e647988 fix(ux) bug in replaceRange dealing with newLines that was breaking vim support (#5320) 2025-07-31 23:16:29 +00:00
Sandy Tao
32809a7be7 feat(cli): Improve @ autocompletion for mid-sentence edits (#5321) 2025-07-31 23:07:12 +00:00
Sandy Tao
150a2568b4 feat(cli): Improve @ autocompletion for mid-sentence edits (#5321) 2025-07-31 23:07:12 +00:00
Paige Bailey
37a3f1e6b6 Add emacs support, as per user requests. :) (#1633)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: matt korwel <matt.korwel@gmail.com>
Co-authored-by: matt korwel <mattkorwel@google.com>
2025-07-31 22:46:04 +00:00
Paige Bailey
598b2cf7f4 Add emacs support, as per user requests. :) (#1633)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: matt korwel <matt.korwel@gmail.com>
Co-authored-by: matt korwel <mattkorwel@google.com>
2025-07-31 22:46:04 +00:00
JeromeJu
574015edd9 feat: Implement /setup-github command (#5069) 2025-07-31 22:14:22 +00:00
JeromeJu
8be10b4c09 feat: Implement /setup-github command (#5069) 2025-07-31 22:14:22 +00:00
Yuki Okita
f9a05401c1 feat: Multi-Directory Workspace Support (part2: add "directory" command) (#5241) 2025-07-31 19:02:08 +00:00
Yuki Okita
0c0881348d feat: Multi-Directory Workspace Support (part2: add "directory" command) (#5241) 2025-07-31 19:02:08 +00:00
Niladri Das
9a6422f331 fix: CLAUDE.md compatibility for GEMINI.md '@' file import behavior (#2978)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-07-31 16:36:50 +00:00
Niladri Das
8550d70a57 fix: CLAUDE.md compatibility for GEMINI.md '@' file import behavior (#2978)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-07-31 16:36:50 +00:00
joshualitt
ae86c7ba05 bug(core): UI reporting for truncated read_file. (#5155)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 16:31:14 +00:00
joshualitt
c80607ac15 bug(core): UI reporting for truncated read_file. (#5155)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 16:31:14 +00:00
anj-s
65be9cab47 Fix: Ensure that non interactive mode and interactive mode are calling the same entry points (#5137) 2025-07-31 12:36:12 +00:00
anj-s
ceccdf9d2c Fix: Ensure that non interactive mode and interactive mode are calling the same entry points (#5137) 2025-07-31 12:36:12 +00:00
Sandy Tao
23c014e29c Replace FlashDecidedToContinueEvent with NextSpeakerCheckEvent (#5257) 2025-07-31 04:47:04 +00:00
Sandy Tao
7ca978f3a0 Replace FlashDecidedToContinueEvent with NextSpeakerCheckEvent (#5257) 2025-07-31 04:47:04 +00:00
Kazunari001
3ef2c6d198 feat(docs): Add /init command in commands.md (#5187)
Co-authored-by: saucykazugmail <saucydog0922@gmail.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 01:52:40 +00:00
Kazunari001
54ec18141c feat(docs): Add /init command in commands.md (#5187)
Co-authored-by: saucykazugmail <saucydog0922@gmail.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 01:52:40 +00:00
Seth Troisi
c77a22d4c6 Add render counter in debug mode (#5242)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 00:43:11 +00:00
Seth Troisi
72af6e077f Add render counter in debug mode (#5242)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 00:43:11 +00:00
Gal Zahavi
d06e17fbd9 Improve error message for discoverTools function (#4157) 2025-07-31 00:16:21 +00:00
Gal Zahavi
152de2b6d8 Improve error message for discoverTools function (#4157) 2025-07-31 00:16:21 +00:00
Shreya Keshive
0c6f788406 Exclude companion extension from release versioning (#5226)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 22:49:26 +00:00
Shreya Keshive
8b645ff688 Exclude companion extension from release versioning (#5226)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 22:49:26 +00:00
christine betts
325bb89137 Add toggleable IDE mode setting (#5146) 2025-07-30 22:36:24 +00:00
christine betts
aad8893322 Add toggleable IDE mode setting (#5146) 2025-07-30 22:36:24 +00:00
Olcan
ac1bb5ee42 confirm save_memory tool, with ability to see diff and edit manually for advanced changes that may override past memories (#5237) 2025-07-30 22:21:31 +00:00
Olcan
e70d2bf6d5 confirm save_memory tool, with ability to see diff and edit manually for advanced changes that may override past memories (#5237) 2025-07-30 22:21:31 +00:00
Allen Hutchison
498edb57ab fix(testing): make ModelStatsDisplay snapshot test deterministic (#5236)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 22:09:32 +00:00
Allen Hutchison
5984eba070 fix(testing): make ModelStatsDisplay snapshot test deterministic (#5236)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 22:09:32 +00:00
christine betts
7bc8766542 Introduce IDE mode installer (#4877) 2025-07-30 21:26:31 +00:00
christine betts
3e1b2dc33a Introduce IDE mode installer (#4877) 2025-07-30 21:26:31 +00:00
Yuki Okita
c1fe688956 feat: Multi-Directory Workspace Support (part1: add --include-directories option) (#4605)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-07-30 20:38:20 +00:00
Yuki Okita
cb6a2161fe feat: Multi-Directory Workspace Support (part1: add --include-directories option) (#4605)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-07-30 20:38:20 +00:00
Srinath Padmanabhan
21965f986c Srithreepo Fixes for Scheduled triage (#5158) 2025-07-30 20:38:02 +00:00
Srinath Padmanabhan
8fabce2c04 Srithreepo Fixes for Scheduled triage (#5158) 2025-07-30 20:38:02 +00:00
shamso-goog
32b1ef3779 feat(ui): Update tool confirmation cancel button text (#4820)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 20:37:51 +00:00
shamso-goog
f7c2091389 feat(ui): Update tool confirmation cancel button text (#4820)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 20:37:51 +00:00
Hyunsu Shin
bcce1e7b84 perf(core): parallelize bfsFileSearch for 40% faster CLI startup (#5185) 2025-07-30 17:32:03 +00:00
Hyunsu Shin
35811d534a perf(core): parallelize bfsFileSearch for 40% faster CLI startup (#5185) 2025-07-30 17:32:03 +00:00
Olcan
bc23009f61 do not mention GEMINI.md in system prompt as it is not fixed and can confuse model as it is not mentioned by memory tool and memory file paths are generally not exposed to model (yet) (#5202) 2025-07-30 17:21:15 +00:00
Olcan
8378fbf7b2 do not mention GEMINI.md in system prompt as it is not fixed and can confuse model as it is not mentioned by memory tool and memory file paths are generally not exposed to model (yet) (#5202) 2025-07-30 17:21:15 +00:00
yaksh gandhi
b447c329db docs: Update chat command documentation with checkpoint locations (#5027)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
Co-authored-by: F. Hinkelmann <franziska.hinkelmann@gmail.com>
2025-07-30 10:01:08 +00:00
yaksh gandhi
658a7b49df docs: Update chat command documentation with checkpoint locations (#5027)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
Co-authored-by: F. Hinkelmann <franziska.hinkelmann@gmail.com>
2025-07-30 10:01:08 +00:00
N. Taylor Mullen
fd434626c5 chore(release): v0.1.15 (#5163) 2025-07-29 22:03:54 -07:00
N. Taylor Mullen
f0d80dfe23 chore(release): v0.1.15 (#5163) 2025-07-29 22:03:54 -07:00
Sandy Tao
8985e489a5 Skip and reset loop checking around code blocks (#5144) 2025-07-30 04:05:03 +00:00
Sandy Tao
85a0ed27f6 Skip and reset loop checking around code blocks (#5144) 2025-07-30 04:05:03 +00:00
Jenna Inouye
0ce89392b8 Docs: add documentation for .geminiignore (#5123)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-07-30 03:36:26 +00:00
Jenna Inouye
61107ef19d Docs: add documentation for .geminiignore (#5123)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-07-30 03:36:26 +00:00
Sambhav Khanna
d5a1b717c2 fix(update): correctly report new updates (#4821)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 00:11:15 +00:00
Sambhav Khanna
0b912e2e09 fix(update): correctly report new updates (#4821)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 00:11:15 +00:00
Allen Hutchison
091804c750 feat(docs): document GEMINI.md import syntax (#5145) 2025-07-29 23:41:31 +00:00
Allen Hutchison
c156fb0e8b feat(docs): document GEMINI.md import syntax (#5145) 2025-07-29 23:41:31 +00:00
Ava
d64c3d6af8 Add Starcraft ref to witty loading phrases (#5120)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-29 23:22:13 +00:00
Ava
1a92614c84 Add Starcraft ref to witty loading phrases (#5120)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-29 23:22:13 +00:00
Tommaso Sciortino
327f915610 Fix typo in RFC 9728 impl (#5126) 2025-07-29 23:03:39 +00:00
Tommaso Sciortino
ed2b4c6aa4 Fix typo in RFC 9728 impl (#5126) 2025-07-29 23:03:39 +00:00
Srinath Padmanabhan
008051e42d Update Triage Logic to improve issue categorization. (#5110) 2025-07-29 21:44:48 +00:00
Srinath Padmanabhan
32c7070d7f Update Triage Logic to improve issue categorization. (#5110) 2025-07-29 21:44:48 +00:00
Shreya Keshive
293bb82019 Adds centralized support to log slash commands + sub commands (#5128) 2025-07-29 20:20:37 +00:00
Shreya Keshive
a2c3dbd189 Adds centralized support to log slash commands + sub commands (#5128) 2025-07-29 20:20:37 +00:00
shamso-goog
80079cd2a5 feat(cli): introduce /init command for GEMINI.md creation (#4852)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-07-29 16:49:01 +00:00
shamso-goog
72d6ef2d3c feat(cli): introduce /init command for GEMINI.md creation (#4852)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-07-29 16:49:01 +00:00
Daniel Lee
7356764a48 feat(commands): add custom commands support for extensions (#4703) 2025-07-29 01:40:47 +00:00
Daniel Lee
bf7fd08f7e feat(commands): add custom commands support for extensions (#4703) 2025-07-29 01:40:47 +00:00
Gal Zahavi
871e0dfab8 feat: Add auto update functionality (#4686) 2025-07-29 00:56:52 +00:00
Gal Zahavi
c42d3b58e1 feat: Add auto update functionality (#4686) 2025-07-29 00:56:52 +00:00
Shreya Keshive
83c4dddb7e Only enable IDE integration if gemini-cli is running in the same path as open workspace (#5068) 2025-07-28 20:55:00 +00:00
Shreya Keshive
69c6808b14 Only enable IDE integration if gemini-cli is running in the same path as open workspace (#5068) 2025-07-28 20:55:00 +00:00
Seth Troisi
1c1aa047ff feat: Add tests for checkpoint tag sanitization (#4882) 2025-07-28 20:43:39 +00:00
Seth Troisi
3091980de2 feat: Add tests for checkpoint tag sanitization (#4882) 2025-07-28 20:43:39 +00:00
Abhi
b08679c906 Add new fallback state as prefactor for routing (#5065) 2025-07-28 19:55:50 +00:00
Abhi
cb39eef7b5 Add new fallback state as prefactor for routing (#5065) 2025-07-28 19:55:50 +00:00
Danny
b6c2c64f9b Adds docs outlining keyboard shortcuts for gemini-cli (#4727)
Co-authored-by: dannyzen <dannyrosen@google.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-28 19:35:06 +00:00
Danny
40db8cde97 Adds docs outlining keyboard shortcuts for gemini-cli (#4727)
Co-authored-by: dannyzen <dannyrosen@google.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-28 19:35:06 +00:00
Shreya Keshive
cfe3753d4c Refactors companion VS Code extension to import & use notification schema defined in gemini-cli (#5059) 2025-07-28 18:20:56 +00:00
Shreya Keshive
787aa624da Refactors companion VS Code extension to import & use notification schema defined in gemini-cli (#5059) 2025-07-28 18:20:56 +00:00
N. Taylor Mullen
9aef0a8e6c Revert "feat: Add /config refresh command" (#5060) 2025-07-28 18:13:46 +00:00
N. Taylor Mullen
56c2d95a4c Revert "feat: Add /config refresh command" (#5060) 2025-07-28 18:13:46 +00:00
Neha Prasad
a5ea113a8e fix: Clear previous thoughts when starting new prompts (#4966) 2025-07-28 17:57:33 +00:00
Neha Prasad
4b3e407d49 fix: Clear previous thoughts when starting new prompts (#4966) 2025-07-28 17:57:33 +00:00
christine betts
379765da23 Add documentation for MCP prompts (#4897)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 16:01:15 +00:00
christine betts
f1f0da6dc9 Add documentation for MCP prompts (#4897)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 16:01:15 +00:00
Alexander Parshakov
f7e559223d docs: Add more examples to Popular tasks (#4979)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 15:54:09 +00:00
Alexander Parshakov
4de893da0d docs: Add more examples to Popular tasks (#4979)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 15:54:09 +00:00
Ramón Medrano Llamas
0170791800 feat: Add /config refresh command (#4993)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 15:46:43 +00:00
Ramón Medrano Llamas
02bf8c16c7 feat: Add /config refresh command (#4993)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 15:46:43 +00:00
Shreya Keshive
e275441651 Updates schema, UX and prompt for IDE context (#5046) 2025-07-28 15:03:22 +00:00
Shreya Keshive
165b29c3b1 Updates schema, UX and prompt for IDE context (#5046) 2025-07-28 15:03:22 +00:00
James Woo
f2e006179d Fix author attribution (#5042) 2025-07-28 14:45:23 +00:00
James Woo
16322ed0b2 Fix author attribution (#5042) 2025-07-28 14:45:23 +00:00
N. Taylor Mullen
bd85070411 Revert "Propagate user_prompt_id to GenerateConentRequest for logging" (#5007) 2025-07-27 19:28:20 -07:00
N. Taylor Mullen
e1f9f90660 Revert "Propagate user_prompt_id to GenerateConentRequest for logging" (#5007) 2025-07-27 19:28:20 -07:00
Jenna Inouye
9ed351260c Update documentation for read_many_files. (#4874)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-07-27 22:25:04 +00:00
Jenna Inouye
0371f638c0 Update documentation for read_many_files. (#4874)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-07-27 22:25:04 +00:00
Jenna Inouye
ab0d9df658 Clarify ToS and privacy documentation FAQs. (#4899) 2025-07-27 22:24:53 +00:00
Jenna Inouye
79703c8ecb Clarify ToS and privacy documentation FAQs. (#4899) 2025-07-27 22:24:53 +00:00
Hiroaki Mitsuyoshi
bce6eb5014 feat(chat): Implement /chat delete command (#2401) 2025-07-27 22:18:12 +00:00
Hiroaki Mitsuyoshi
f3ffb00ed0 feat(chat): Implement /chat delete command (#2401) 2025-07-27 22:18:12 +00:00
Leeroy Ding
9ca48c00a6 fix: yolo mode not respected (#4972) 2025-07-27 21:42:26 +00:00
Leeroy Ding
9d07de7a5b fix: yolo mode not respected (#4972) 2025-07-27 21:42:26 +00:00
Abhi
0b5cc96362 (model) - Use Flash Lite For Next Speaker Checks (#4991) 2025-07-27 21:40:55 +00:00
Abhi
3a384784d7 (model) - Use Flash Lite For Next Speaker Checks (#4991) 2025-07-27 21:40:55 +00:00
owenofbrien
b497791c59 Propagate user_prompt_id to GenerateConentRequest for logging (#4741) 2025-07-27 21:34:39 +00:00
owenofbrien
e7b90f54e6 Propagate user_prompt_id to GenerateConentRequest for logging (#4741) 2025-07-27 21:34:39 +00:00
Abhi
36e1e57252 (docs) - Fix small markdown mistake for custom commands docs (#4983) 2025-07-27 21:33:58 +00:00
Abhi
8e983466f8 (docs) - Fix small markdown mistake for custom commands docs (#4983) 2025-07-27 21:33:58 +00:00
Hyeladi Bassi
a9f04eba2c refactor(telemetry): enhance flushToClearcut method with retry logic and early return for empty events (#1601)
Co-authored-by: Scott Densmore <scottdensmore@mac.com>
2025-07-27 18:18:27 +00:00
Hyeladi Bassi
1f013c969f refactor(telemetry): enhance flushToClearcut method with retry logic and early return for empty events (#1601)
Co-authored-by: Scott Densmore <scottdensmore@mac.com>
2025-07-27 18:18:27 +00:00
320 changed files with 33830 additions and 7383 deletions

View File

@@ -0,0 +1,65 @@
name: Build and Publish Docker Image
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
publish:
description: 'Publish to GHCR (only works on main branch)'
type: boolean
default: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-to-ghcr:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-,format=short
- name: Log in to the Container registry
if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
CLI_VERSION_ARG=${{ github.sha }}

View File

@@ -1,4 +1,4 @@
name: Gemini Automated Issue Triage
name: Qwen Automated Issue Triage
on:
issues:
@@ -7,7 +7,7 @@ on:
jobs:
triage-issue:
timeout-minutes: 5
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
if: ${{ github.repository == 'QwenLM/qwen-code' }}
permissions:
issues: write
contents: read
@@ -17,22 +17,15 @@ jobs:
cancel-in-progress: true
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
- name: Run Gemini Issue Triage
uses: google-gemini/gemini-cli-action@df3f890f003d28c60a2a09d2c29e0126e4d1e2ff
- name: Run Qwen Issue Triage
uses: QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
with:
version: 0.1.8-rc.0
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }}
OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}
version: 0.0.7
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
settings_json: |
{
"coreTools": [
@@ -40,24 +33,100 @@ jobs:
"run_shell_command(gh issue edit)",
"run_shell_command(gh issue list)"
],
"telemetry": {
"enabled": true,
"target": "gcp"
},
"sandbox": false
}
prompt: |
You are an issue triage assistant. Analyze the current GitHub issue and apply the most appropriate existing labels.
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue.
Steps:
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Review the issue title and body provided in the environment variables.
3. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, and priority/*.
4. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`
5. If the issue has a "status/need-triage" label, remove it after applying the appropriate labels: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --remove-label "status/need-triage"`
2. Review the issue title, body and any comments provided in the environment variables.
3. Ignore any existing priorities or tags on the issue. Just report your findings.
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`
7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label
8. If you see that the issue doesnt look like it has sufficient information recommend the status/need-information label
9. Use Area definitions mentioned below to help you narrow down issues
Guidelines:
- Only use labels that already exist in the repository.
- Do not add comments or modify the issue content.
- Triage only the current issue.
- Assign all applicable kind/*, area/*, and priority/* labels based on the issue content.
- Apply only one area/ label
- Apply only one kind/ label
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
Categorization Guidelines:
P0: Critical / Blocker
- A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself.
Impact:
- Blocks development or testing for the entire team.
- Major security vulnerability that could compromise user data or system integrity.
- Causes data loss or corruption with no workaround.
- Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? Is it preventing contributors from contributing to the repository or is it a release blocker?
Qualifier: Is the main function of the software broken?
Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
P1: High
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1.
Impact:
- A core feature is broken or behaving incorrectly for a large number of users or large number of use cases.
- Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases.
- Severe performance degradation making the application frustratingly slow.
- No straightforward workaround exists, or the workaround is difficult and non-obvious.
Qualifier: Is a key feature unusable or giving very wrong results?
Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable.
P2: Medium
- A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality.
Impact:
- Affects a non-critical feature or a smaller, specific subset of users.
- An inconvenient but functional workaround is available and easy to execute.
- Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping).
Qualifier: Is it an annoying but non-blocking problem?
Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow.
P3: Low
- A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application.
Impact:
- Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page.
- An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users.
Qualifier: Is it a "nice-to-fix" issue?
Example: Spelling mistakes etc.
Things you should know:
- If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue
- This product is designed to use different models eg.. using pro, downgrading to flash etc. when users report that they dont expect the model to change those would be categorized as feature requests.
Definition of Areas
area/ux:
- Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance.
- I am seeing my screen flicker when using Gemini CLI
- I am seeing the output malformed
- Theme changes aren't taking effect
- My keyboard inputs arent' being recognzied
area/platform:
- Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework.
area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features.
area/models:
- i am not getting a response that is reasonable or expected. this can include things like
- I am calling a tool and the tool is not performing as expected.
- i am expecting a tool to be called and it is not getting called ,
- Including experience when using
- built-in tools (e.g., web search, code interpreter, read file, writefile, etc..),
- Function calling issues should be under this area
- i am getting responses from the model that are malformed.
- Issues concerning Gemini quality of response and inference,
- Issues talking about unnecessary token consumption.
- Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues.
- Memory compression
- unexpected responses,
- poor quality of generated code
area/tools:
- These are primarily issues related to Model Context Protocol
- These are issues that mention MCP support
- feature requests asking for support for new tools.
area/core: Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality
area/contribution: Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure.
area/authentication: Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc..
area/security-privacy: Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access.
area/extensibility: Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc..
area/performance: Issues focused on model performance
- Issues with running out of capacity,
- 429 errors etc..
- could also pertain to latency,
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
- Switching models from one to the other unexpectedly.

View File

@@ -1,4 +1,4 @@
name: Gemini Scheduled Issue Triage
name: Qwen Scheduled Issue Triage
on:
schedule:
@@ -8,24 +8,17 @@ on:
jobs:
triage-issues:
timeout-minutes: 10
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
if: ${{ github.repository == 'QwenLM/qwen-code' }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
issues: write
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
- name: Find untriaged issues
id: find_issues
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "🔍 Finding issues without labels..."
NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body)
@@ -41,60 +34,147 @@ jobs:
echo "✅ Found $(echo "$ISSUES" | jq 'length') issues to triage! 🎯"
- name: Run Gemini Issue Triage
- name: Run Qwen Issue Triage
if: steps.find_issues.outputs.issues_to_triage != '[]'
uses: google-gemini/gemini-cli-action@df3f890f003d28c60a2a09d2c29e0126e4d1e2ff
uses: QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
REPOSITORY: ${{ github.repository }}
with:
version: 0.1.8-rc.0
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }}
OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}
version: 0.0.7
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
settings_json: |
{
"coreTools": [
"run_shell_command(echo)",
"run_shell_command(gh label list)",
"run_shell_command(gh issue edit)",
"run_shell_command(gh issue list)"
"run_shell_command(gh issue list)",
"run_shell_command(gh issue view)"
],
"telemetry": {
"enabled": true,
"target": "gcp"
},
"sandbox": false
}
prompt: |
You are an issue triage assistant. Analyze issues and apply appropriate labels ONE AT A TIME.
Repository: ${{ github.repository }}
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels.
Steps:
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to see available labels
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
3. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands:
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"`
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"`
- Continue for each label separately
IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed.
Guidelines:
- Only use existing repository labels from step 1
- Do not add comments to issues
- Triage each issue independently based on title and body content
- Focus on applying: kind/* (bug/enhancement/documentation), area/* (core/cli/testing/windows), and priority/* labels
- If an issue has insufficient information, consider applying "status/need-information"
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
Example triage logic:
- Issues with "bug", "error", "broken" → kind/bug
- Issues with "feature", "enhancement", "improve" → kind/enhancement
- Issues about Windows/performance → area/windows, area/performance
- Critical bugs → priority/p0, other bugs → priority/p1, enhancements → priority/p2
3. Review the issue title, body and any comments provided in the environment variables.
4. Ignore any existing priorities or tags on the issue.
5. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*.
6. Get the list of labels already on the issue using `gh issue view ISSUE_NUMBER --repo ${{ github.repository }} --json labels -t '{{range .labels}}{{.name}}{{"\n"}}{{end}}'
7. For area/* and kind/* limit yourself to only the single most applicable label in each case.
8. Give me a single short paragraph about why you are selecting each label in the process. use the format Issue ID: , Title, Label applied:, Label removed, ovearll explanation
9. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands:
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"`
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"`
- Continue for each label separately
- IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed.
- Make sure after you apply labels there is only one area/* and one kind/* label per issue.
- To do this look for labels found in step 6 that no longer apply remove them one at a time using
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name1"`
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name2"`
- IMPORTANT: Remove each label one at a time, one command per issue if needed.
10. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
11. If you see that the issue doesnt look like it has sufficient information recommend the status/need-information label
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
Process each issue sequentially and confirm each labeling operation before moving to the next issue.
Guidelines:
- Only use labels that already exist in the repository.
- Do not add comments or modify the issue content.
- Do not remove labels titled help wanted or good first issue.
- Triage only the current issue.
- Apply only one area/ label
- Apply only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
Categorization Guidelines:
P0: Critical / Blocker
- A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself.
Impact:
- Blocks development or testing for the entire team.
- Major security vulnerability that could compromise user data or system integrity.
- Causes data loss or corruption with no workaround.
- Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration?
- Is it preventing contributors from contributing to the repository or is it a release blocker?
Qualifier: Is the main function of the software broken?
Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
P1: High
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution.
- Feature requests are almost never P1.
Impact:
- A core feature is broken or behaving incorrectly for a large number of users or large number of use cases.
- Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases.
- Severe performance degradation making the application frustratingly slow.
- No straightforward workaround exists, or the workaround is difficult and non-obvious.
Qualifier: Is a key feature unusable or giving very wrong results?
Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable.
P2: Medium
- A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality.
Impact:
- Affects a non-critical feature or a smaller, specific subset of users.
- An inconvenient but functional workaround is available and easy to execute.
- Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping).
Qualifier: Is it an annoying but non-blocking problem?
Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow.
P3: Low
- A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application.
Impact:
- Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page.
- An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users.
Qualifier: Is it a "nice-to-fix" issue?
Example: Spelling mistakes etc.
Additional Context:
- If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
- When users report that they dont expect the model to change those would be categorized as feature requests.
Definition of Areas
area/ux:
- Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance.
- I am seeing my screen flicker when using Gemini CLI
- I am seeing the output malformed
- Theme changes aren't taking effect
- My keyboard inputs arent' being recognzied
area/platform:
- Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework.
area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features.
area/models:
- i am not getting a response that is reasonable or expected. this can include things like
- I am calling a tool and the tool is not performing as expected.
- i am expecting a tool to be called and it is not getting called ,
- Including experience when using
- built-in tools (e.g., web search, code interpreter, read file, writefile, etc..),
- Function calling issues should be under this area
- i am getting responses from the model that are malformed.
- Issues concerning Gemini quality of response and inference,
- Issues talking about unnecessary token consumption.
- Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues.
- Memory compression
- unexpected responses,
- poor quality of generated code
area/tools:
- These are primarily issues related to Model Context Protocol
- These are issues that mention MCP support
- feature requests asking for support for new tools.
area/core:
- Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality
area/contribution:
- Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure.
area/authentication:
- Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc..
area/security-privacy:
- Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access.
area/extensibility:
- Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc..
area/performance:
- Issues focused on model performance
- Issues with running out of capacity,
- 429 errors etc..
- could also pertain to latency,
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
- Switching models from one to the other unexpectedly.

View File

@@ -1,4 +1,4 @@
name: Gemini Scheduled PR Triage 🚀
name: Qwen Scheduled PR Triage 🚀
on:
schedule:
@@ -8,7 +8,7 @@ on:
jobs:
audit-prs:
timeout-minutes: 15
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
if: ${{ github.repository == 'QwenLM/qwen-code' }}
permissions:
contents: read
id-token: write
@@ -21,16 +21,9 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
- name: Run PR Triage Script
id: run_triage
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: ./.github/scripts/pr-triage.sh

32
.github/workflows/no-response.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: No Response
# Run as a daily cron at 1:45 AM
on:
schedule:
- cron: '45 1 * * *'
workflow_dispatch: {}
jobs:
no-response:
runs-on: ubuntu-latest
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
permissions:
issues: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-no-response
cancel-in-progress: true
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: -1
days-before-close: 14
stale-issue-label: 'status/need-information'
close-issue-message: >
This issue was marked as needing more information and has not received a response in 14 days.
Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!
stale-pr-label: 'status/need-information'
close-pr-message: >
This pull request was marked as needing more information and has had no updates in 14 days.
Closing it for now. You are welcome to reopen with the required info. Thanks for contributing!

View File

@@ -0,0 +1,195 @@
name: 🧐 Qwen Pull Request Review
on:
pull_request_target:
types: [opened]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
required: true
type: number
jobs:
review-pr:
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_target' &&
github.event.action == 'opened' &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')) ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@qwen /review') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
(github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@qwen /review') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
(github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@qwen /review') &&
(github.event.review.author_association == 'OWNER' ||
github.event.review.author_association == 'MEMBER' ||
github.event.review.author_association == 'COLLABORATOR'))
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
issues: write
steps:
- name: Checkout PR code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Get PR details (pull_request_target & workflow_dispatch)
id: get_pr
if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_NUMBER=${{ github.event.inputs.pr_number }}
else
PR_NUMBER=${{ github.event.pull_request.number }}
fi
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
# Get PR details
PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName)
echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT"
# Get file changes
CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only)
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Get PR details (issue_comment)
id: get_pr_comment
if: github.event_name == 'issue_comment'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
PR_NUMBER=${{ github.event.issue.number }}
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
# Extract additional instructions from comment
ADDITIONAL_INSTRUCTIONS=$(echo "$COMMENT_BODY" | sed 's/.*@qwen \/review//' | xargs)
echo "additional_instructions=$ADDITIONAL_INSTRUCTIONS" >> "$GITHUB_OUTPUT"
# Get PR details
PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName)
echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT"
# Get file changes
CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only)
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Run Qwen PR Review
uses: QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.get_pr.outputs.pr_number || steps.get_pr_comment.outputs.pr_number }}
PR_DATA: ${{ steps.get_pr.outputs.pr_data || steps.get_pr_comment.outputs.pr_data }}
CHANGED_FILES: ${{ steps.get_pr.outputs.changed_files || steps.get_pr_comment.outputs.changed_files }}
ADDITIONAL_INSTRUCTIONS: ${{ steps.get_pr.outputs.additional_instructions || steps.get_pr_comment.outputs.additional_instructions }}
REPOSITORY: ${{ github.repository }}
with:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
settings_json: |
{
"coreTools": [
"run_shell_command(echo)",
"run_shell_command(gh pr view)",
"run_shell_command(gh pr diff)",
"run_shell_command(gh pr comment)",
"run_shell_command(cat)",
"run_shell_command(head)",
"run_shell_command(tail)",
"run_shell_command(grep)",
"write_file"
],
"sandbox": false
}
prompt: |
You are an expert code reviewer. You have access to shell commands to gather PR information and perform the review.
IMPORTANT: Use the available shell commands to gather information. Do not ask for information to be provided.
Start by running these commands to gather the required data:
1. Run: echo "$PR_DATA" to get PR details (JSON format)
2. Run: echo "$CHANGED_FILES" to get the list of changed files
3. Run: echo "$PR_NUMBER" to get the PR number
4. Run: echo "$ADDITIONAL_INSTRUCTIONS" to see any specific review instructions from the user
5. Run: gh pr diff $PR_NUMBER to see the full diff
6. For any specific files, use: cat filename, head -50 filename, or tail -50 filename
Additional Review Instructions:
If ADDITIONAL_INSTRUCTIONS contains text, prioritize those specific areas or focus points in your review.
Common instruction examples: "focus on security", "check performance", "review error handling", "check for breaking changes"
Once you have the information, provide a comprehensive code review by:
1. Writing your review to a file: write_file("review.md", "<your detailed review feedback here>")
2. Posting the review: gh pr comment $PR_NUMBER --body-file review.md --repo $REPOSITORY
Review Areas:
- **Security**: Authentication, authorization, input validation, data sanitization
- **Performance**: Algorithms, database queries, caching, resource usage
- **Reliability**: Error handling, logging, testing coverage, edge cases
- **Maintainability**: Code structure, documentation, naming conventions
- **Functionality**: Logic correctness, requirements fulfillment
Output Format:
Structure your review using this exact format with markdown:
## 📋 Review Summary
Provide a brief 2-3 sentence overview of the PR and overall assessment.
## 🔍 General Feedback
- List general observations about code quality
- Mention overall patterns or architectural decisions
- Highlight positive aspects of the implementation
- Note any recurring themes across files
## 🎯 Specific Feedback
Only include sections below that have actual issues. If there are no issues in a priority category, omit that entire section.
### 🔴 Critical
(Only include this section if there are critical issues)
Issues that must be addressed before merging (security vulnerabilities, breaking changes, major bugs):
- **File: `filename:line`** - Description of critical issue with specific recommendation
### 🟡 High
(Only include this section if there are high priority issues)
Important issues that should be addressed (performance problems, design flaws, significant bugs):
- **File: `filename:line`** - Description of high priority issue with suggested fix
### 🟢 Medium
(Only include this section if there are medium priority issues)
Improvements that would enhance code quality (style issues, minor optimizations, better practices):
- **File: `filename:line`** - Description of medium priority improvement
### 🔵 Low
(Only include this section if there are suggestions)
Nice-to-have improvements and suggestions (documentation, naming, minor refactoring):
- **File: `filename:line`** - Description of suggestion or enhancement
**Note**: If no specific issues are found in any category, simply state "No specific issues identified in this review."
## ✅ Highlights
(Only include this section if there are positive aspects to highlight)
- Mention specific good practices or implementations
- Acknowledge well-written code sections
- Note improvements from previous versions

38
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Mark stale issues and pull requests
# Run as a daily cron at 1:30 AM
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch: {}
jobs:
stale:
runs-on: ubuntu-latest
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
permissions:
issues: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-stale
cancel-in-progress: true
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: >
This issue has been automatically marked as stale due to 60 days of inactivity.
It will be closed in 14 days if no further activity occurs.
stale-pr-message: >
This pull request has been automatically marked as stale due to 60 days of inactivity.
It will be closed in 14 days if no further activity occurs.
close-issue-message: >
This issue has been closed due to 14 additional days of inactivity after being marked as stale.
If you believe this is still relevant, feel free to comment or reopen the issue. Thank you!
close-pr-message: >
This pull request has been closed due to 14 additional days of inactivity after being marked as stale.
If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing!
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: pinned,security
exempt-pr-labels: pinned,security

2
.npmrc
View File

@@ -1 +1 @@
@google:registry=https://wombat-dressing-room.appspot.com
registry=https://registry.npmjs.org

2
.vscode/launch.json vendored
View File

@@ -50,7 +50,7 @@
"type": "node",
// fix source mapping when debugging in sandbox using global installation
// note this does not interfere when remoteRoot is also ${workspaceFolder}/packages
"remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@gemini-cli",
"remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@qwen-code",
"localRoot": "${workspaceFolder}/packages"
},
{

26
CHANGELOG.md Normal file
View File

@@ -0,0 +1,26 @@
# Changelog
## 0.0.7
- Fix MCP tools
- Fix Web Fetch tool
- Fix Web Search tool, by replacing web search from Google/Gemini to Tavily API
- Fix: Compatible with occasional tool call parameters returned by LLM that are invalid JSON
- Fix: prevent concurrent query submissions on some rare cases
- Fix: incorrect qwen logger exit handler setup
- Fix: seperate static QR code and dynamic spin components
- Sync gemini-cli to v0.1.18
## 0.0.6
- Add usage statistics logging for Qwen integration
- Make `/init` command respect configured context filename and align docs with QWEN.md
- Fix EPERM error when run `qwen --sandbox` in macOS
- Fix terminal flicker when waiting for login
- Fix `glm-4.5` model request error
## 0.0.5
- Support Qwen OAuth login and provide up to 2000 free requests per day
- Sync gemini-cli to v0.1.17
- Add systemPromptMappings Configuration Feature

View File

@@ -242,6 +242,8 @@ To hit a breakpoint inside the sandbox container run:
DEBUG=1 gemini
```
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
### React DevTools
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x.

View File

@@ -1,3 +1,31 @@
# Build stage
FROM docker.io/library/node:20-slim AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up npm global package folder
RUN mkdir -p /usr/local/share/npm-global
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Copy source code
COPY . /home/node/app
WORKDIR /home/node/app
# Install dependencies and build packages
RUN npm ci \
&& npm run build --workspaces \
&& npm pack -w @qwen-code/qwen-code --pack-destination ./packages/cli/dist \
&& npm pack -w @qwen-code/qwen-code-core --pack-destination ./packages/core/dist
# Runtime stage
FROM docker.io/library/node:20-slim
ARG SANDBOX_NAME="qwen-code-sandbox"
@@ -5,11 +33,9 @@ ARG CLI_VERSION_ARG
ENV SANDBOX="$SANDBOX_NAME"
ENV CLI_VERSION=$CLI_VERSION_ARG
# install minimal set of packages, then clean up
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
man-db \
curl \
dnsutils \
@@ -29,22 +55,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# set up npm global package folder under /usr/local/share
# give it to non-root user node, already set up in base image
RUN mkdir -p /usr/local/share/npm-global \
&& chown -R node:node /usr/local/share/npm-global
# Set up npm global package folder
RUN mkdir -p /usr/local/share/npm-global
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# switch to non-root user node
USER node
# Copy built packages from builder stage
COPY --from=builder /home/node/app/packages/cli/dist/*.tgz /tmp/
COPY --from=builder /home/node/app/packages/core/dist/*.tgz /tmp/
# install qwen-code and clean up
COPY packages/cli/dist/qwen-code-*.tgz /usr/local/share/npm-global/qwen-code.tgz
COPY packages/core/dist/qwen-code-qwen-code-core-*.tgz /usr/local/share/npm-global/qwen-code-core.tgz
RUN npm install -g /usr/local/share/npm-global/qwen-code.tgz /usr/local/share/npm-global/qwen-code-core.tgz \
# Install built packages globally
RUN npm install -g /tmp/*.tgz \
&& npm cache clean --force \
&& rm -f /usr/local/share/npm-global/qwen-{code,code-core}.tgz
&& rm -rf /tmp/*.tgz
# default entrypoint when none specified
CMD ["qwen"]
# Default entrypoint when none specified
CMD ["qwen"]

View File

@@ -188,6 +188,7 @@
identification within third-party archives.
Copyright 2025 Google LLC
Copyright 2025 Qwen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@@ -53,7 +53,7 @@ debug:
run-npx:
npx https://github.com/google-gemini/gemini-cli
npx https://github.com/QwenLM/qwen-code
create-alias:
scripts/create_alias.sh

View File

@@ -17,10 +17,27 @@
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
## 💡 Free Options Available
Get started with Qwen Code at no cost using any of these free options:
### 🔥 Qwen OAuth (Recommended)
- **2,000 requests per day** with no token limits
- **60 requests per minute** rate limit
- Simply run `qwen` and authenticate with your qwen.ai account
- Automatic credential management and refresh
- Use `/auth` command to switch to Qwen OAuth if you have initialized with OpenAI compatible mode
### 🌏 Regional Free Tiers
- **Mainland China**: ModelScope offers **2,000 free API calls per day**
- **International**: OpenRouter provides **up to 1,000 free API calls per day** worldwide
For detailed setup instructions, see [Authorization](#authorization).
> [!WARNING]
> **Token Usage Notice**: Qwen Code may issue multiple API calls per cycle, resulting in higher token usage (similar to Claude Code). We're actively optimizing API efficiency.
>
> 💡 **Free Option**: ModelScope provides **2,000 free API calls per day** for users in mainland China. OpenRouter offers up to **1,000 free API calls per day** worldwide. For setup instructions, see [API Configuration](#api-configuration).
## Key Features
@@ -84,15 +101,43 @@ Create or edit `.qwen/settings.json` in your home directory:
- **`/compress`** - Compress conversation history to continue within token limits
- **`/clear`** - Clear all conversation history and start fresh
- **`/status`** - Check current token usage and limits
- **`/stats`** - Check current token usage and limits
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
### API Configuration
### Authorization
Qwen Code supports multiple API providers. You can configure your API key through environment variables or a `.env` file in your project root.
Choose your preferred authentication method based on your needs:
#### Configuration Methods
#### 1. Qwen OAuth (🚀 Recommended - Start in 30 seconds)
The easiest way to get started - completely free with generous quotas:
```bash
# Just run this command and follow the browser authentication
qwen
```
**What happens:**
1. **Instant Setup**: CLI opens your browser automatically
2. **One-Click Login**: Authenticate with your qwen.ai account
3. **Automatic Management**: Credentials cached locally for future use
4. **No Configuration**: Zero setup required - just start coding!
**Free Tier Benefits:**
-**2,000 requests/day** (no token counting needed)
-**60 requests/minute** rate limit
-**Automatic credential refresh**
-**Zero cost** for individual users
- **Note**: Model fallback may occur to maintain service quality
#### 2. OpenAI-Compatible API
Use API keys for OpenAI or other compatible providers:
**Configuration Methods:**
1. **Environment Variables**
@@ -110,7 +155,7 @@ Qwen Code supports multiple API providers. You can configure your API key throug
OPENAI_MODEL=your_model_choice
```
#### API Provider Options
**API Provider Options**
> ⚠️ **Regional Notice:**
>
@@ -265,7 +310,7 @@ qwen
- `/help` - Display available commands
- `/clear` - Clear conversation history
- `/compress` - Compress history to save tokens
- `/status` - Show current session information
- `/stats` - Show current session information
- `/exit` or `/quit` - Exit Qwen Code
### Keyboard Shortcuts
@@ -287,6 +332,8 @@ qwen
See [CONTRIBUTING.md](./CONTRIBUTING.md) to learn how to contribute to the project.
For detailed authentication setup, see the [authentication guide](./docs/cli/authentication.md).
## Troubleshooting
If you encounter issues, check the [troubleshooting guide](docs/troubleshooting.md).

View File

@@ -1,4 +1,4 @@
# Gemini CLI Roadmap
# Qwen CLI Roadmap
The [Official Gemini CLI Roadmap](https://github.com/orgs/google-gemini/projects/11/)
@@ -56,7 +56,7 @@ find initiatives that interest you.
Gemini CLI is an open-source project, and we welcome contributions from the community! Whether you're a developer, a designer, or just an enthusiastic user you can find our [Community Guidelines here](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) to learn how to get started. There are many ways to get involved:
- **Roadmap:** Please review and find areas in our [roadmap](https://github.com/google-gemini/gemini-cli/issues/4191) that you would like to contribute to. Contributions based on this will be easiest to integrate with.
- **Report Bugs:** If you find an issue, please create a bug(https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priorty/p0`.
- **Report Bugs:** If you find an issue, please create a [bug](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priority/p0`.
- **Suggest Features:** Have a great idea? We'd love to hear it! Open a [feature request](https://github.com/google-gemini/gemini-cli/issues/new?template=feature_request.yml).
- **Contribute Code:** Check out our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) file for guidelines on how to submit pull requests. We have a list of "good first issues" for new contributors.
- **Write Documentation:** Help us improve our documentation, tutorials, and examples.

8
SECURITY.md Normal file
View File

@@ -0,0 +1,8 @@
# Reporting Security Issues
To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz).
We use g.co/vulnz for our intake, and do coordination and disclosure here on
GitHub (including using GitHub Security Advisory). The Google Security Team will
respond within 5 working days of your report on g.co/vulnz.
[GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories

View File

@@ -1,104 +1,93 @@
# Authentication Setup
The Gemini CLI requires you to authenticate with Google's AI services. On initial startup you'll need to configure **one** of the following authentication methods:
Qwen Code supports two main authentication methods to access AI models. Choose the method that best fits your use case:
1. **Login with Google (Gemini Code Assist):**
- Use this option to log in with your google account.
- During initial startup, Gemini CLI will direct you to a webpage for authentication. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
- Note that the web login must be done in a browser that can communicate with the machine Gemini CLI is being run from. (Specifically, the browser will be redirected to a localhost url that Gemini CLI will be listening on).
- <a id="workspace-gca">Users may have to specify a GOOGLE_CLOUD_PROJECT if:</a>
1. You have a Google Workspace account. Google Workspace is a paid service for businesses and organizations that provides a suite of productivity tools, including a custom email domain (e.g. your-name@your-company.com), enhanced security features, and administrative controls. These accounts are often managed by an employer or school.
1. You have received a Gemini Code Assist license through the [Google Developer Program](https://developers.google.com/program/plans-and-pricing) (including qualified Google Developer Experts)
1. You have been assigned a license to a current Gemini Code Assist standard or enterprise subscription.
1. You are using the product outside the [supported regions](https://developers.google.com/gemini-code-assist/resources/available-locations) for free individual usage.
1. You are a Google account holder under the age of 18
- If you fall into one of these categories, you must first configure a Google Cloud Project ID to use, [enable the Gemini for Cloud API](https://cloud.google.com/gemini/docs/discover/set-up-gemini#enable-api) and [configure access permissions](https://cloud.google.com/gemini/docs/discover/set-up-gemini#grant-iam).
1. **Qwen OAuth (Recommended):**
- Use this option to log in with your qwen.ai account.
- During initial startup, Qwen Code will direct you to the qwen.ai authentication page. Once authenticated, your credentials will be cached locally so the web login can be skipped on subsequent runs.
- **Requirements:**
- Valid qwen.ai account
- Internet connection for initial authentication
- **Benefits:**
- Seamless access to Qwen models
- Automatic credential refresh
- No manual API key management required
You can temporarily set the environment variable in your current shell session using the following command:
**Getting Started:**
```bash
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
```
```bash
# Start Qwen Code and follow the OAuth flow
qwen
```
- For repeated use, you can add the environment variable to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following command adds the environment variable to a `~/.bashrc` file:
The CLI will automatically open your browser and guide you through the authentication process.
```bash
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
source ~/.bashrc
```
**For users who authenticate using their qwen.ai account:**
2. **<a id="gemini-api-key"></a>Gemini API key:**
- Obtain your API key from Google AI Studio: [https://aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)
- Set the `GEMINI_API_KEY` environment variable. In the following methods, replace `YOUR_GEMINI_API_KEY` with the API key you obtained from Google AI Studio:
- You can temporarily set the environment variable in your current shell session using the following command:
```bash
export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
```
- For repeated use, you can add the environment variable to your [.env file](#persisting-environment-variables-with-env-files).
**Quota:**
- 60 requests per minute
- 2,000 requests per day
- Token usage is not applicable
- Alternatively you can export the API key from your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following command adds the environment variable to a `~/.bashrc` file:
**Cost:** Free
```bash
echo 'export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"' >> ~/.bashrc
source ~/.bashrc
```
**Notes:** A specific quota for different models is not specified; model fallback may occur to preserve shared experience quality.
:warning: Be advised that when you export your API key inside your shell configuration file, any other process executed from the shell can read it.
2. **<a id="openai-api"></a>OpenAI-Compatible API:**
- Use API keys for OpenAI or other compatible providers.
- This method allows you to use various AI models through API keys.
3. **Vertex AI:**
- Obtain your Google Cloud API key: [Get an API Key](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys?usertype=newuser)
- Set the `GOOGLE_API_KEY` environment variable. In the following methods, replace `YOUR_GOOGLE_API_KEY` with your Vertex AI API key:
- You can temporarily set these environment variables in your current shell session using the following commands:
```bash
export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"
```
- For repeated use, you can add the environment variables to your [.env file](#persisting-environment-variables-with-env-files) or your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following commands add the environment variables to a `~/.bashrc` file:
```bash
echo 'export GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY"' >> ~/.bashrc
source ~/.bashrc
```
- To use Application Default Credentials (ADC), use the following command:
- Ensure you have a Google Cloud project and have enabled the Vertex AI API.
```bash
gcloud auth application-default login
```
For more information, see [Set up Application Default Credentials for Google Cloud](https://cloud.google.com/docs/authentication/provide-credentials-adc).
- Set the `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` environment variables. In the following methods, replace `YOUR_PROJECT_ID` and `YOUR_PROJECT_LOCATION` with the relevant values for your project:
- You can temporarily set these environment variables in your current shell session using the following commands:
```bash
export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"
export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" # e.g., us-central1
```
- For repeated use, you can add the environment variables to your [.env file](#persisting-environment-variables-with-env-files)
**Configuration Methods:**
- Alternatively you can export the environment variables from your shell's configuration file (like `~/.bashrc`, `~/.zshrc`, or `~/.profile`). For example, the following commands add the environment variables to a `~/.bashrc` file:
a) **Environment Variables:**
```bash
echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc
echo 'export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION"' >> ~/.bashrc
source ~/.bashrc
```
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="your_api_endpoint" # Optional
export OPENAI_MODEL="your_model_choice" # Optional
```
:warning: Be advised that when you export your API key inside your shell configuration file, any other process executed from the shell can read it.
b) **Project `.env` File:**
Create a `.env` file in your project root:
4. **Cloud Shell:**
- This option is only available when running in a Google Cloud Shell environment.
- It automatically uses the credentials of the logged-in user in the Cloud Shell environment.
- This is the default authentication method when running in Cloud Shell and no other method is configured.
```env
OPENAI_API_KEY=your_api_key_here
OPENAI_BASE_URL=your_api_endpoint
OPENAI_MODEL=your_model_choice
```
:warning: Be advised that when you export your API key inside your shell configuration file, any other process executed from the shell can read it.
**Supported Providers:**
- OpenAI (https://platform.openai.com/api-keys)
- Alibaba Cloud Bailian
- ModelScope
- OpenRouter
- Azure OpenAI
- Any OpenAI-compatible API
## Switching Authentication Methods
To switch between authentication methods during a session, use the `/auth` command in the CLI interface:
```bash
# Within the CLI, type:
/auth
```
This will allow you to reconfigure your authentication method without restarting the application.
### Persisting Environment Variables with `.env` Files
You can create a **`.gemini/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.gemini/.env` is recommended to keep Gemini variables isolated from other tools.
You can create a **`.qwen/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.qwen/.env` is recommended to keep Qwen Code variables isolated from other tools.
Gemini CLI automatically loads environment variables from the **first** `.env` file it finds, using the following search order:
**Important:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files to prevent interference with qwen-code behavior. Use `.qwen/.env` files for qwen-code specific variables.
Qwen Code automatically loads environment variables from the **first** `.env` file it finds, using the following search order:
1. Starting in the **current directory** and moving upward toward `/`, for each directory it checks:
1. `.gemini/.env`
1. `.qwen/.env`
2. `.env`
2. If no file is found, it falls back to your **home directory**:
- `~/.gemini/.env`
- `~/.qwen/.env`
- `~/.env`
> **Important:** The search stops at the **first** file encountered—variables are **not merged** across multiple files.
@@ -108,37 +97,47 @@ Gemini CLI automatically loads environment variables from the **first** `.env` f
**Project-specific overrides** (take precedence when you are inside the project):
```bash
mkdir -p .gemini
echo 'GOOGLE_CLOUD_PROJECT="your-project-id"' >> .gemini/.env
mkdir -p .qwen
cat >> .qwen/.env <<'EOF'
OPENAI_API_KEY="your-api-key"
OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
EOF
```
**User-wide settings** (available in every directory):
```bash
mkdir -p ~/.gemini
cat >> ~/.gemini/.env <<'EOF'
GOOGLE_CLOUD_PROJECT="your-project-id"
GEMINI_API_KEY="your-gemini-api-key"
mkdir -p ~/.qwen
cat >> ~/.qwen/.env <<'EOF'
OPENAI_API_KEY="your-api-key"
OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
OPENAI_MODEL="qwen3-coder-plus"
EOF
```
## Non-Interactive Mode / Headless Environments
When running the Gemini CLI in a non-interactive environment, you cannot use the interactive login flow.
When running Qwen Code in a non-interactive environment, you cannot use the OAuth login flow.
Instead, you must configure authentication using environment variables.
The CLI will automatically detect if it is running in a non-interactive terminal and will use one of the
following authentication methods if available:
The CLI will automatically detect if it is running in a non-interactive terminal and will use the
OpenAI-compatible API method if configured:
1. **Gemini API Key:**
- Set the `GEMINI_API_KEY` environment variable.
- The CLI will use this key to authenticate with the Gemini API.
1. **OpenAI-Compatible API:**
- Set the `OPENAI_API_KEY` environment variable.
- Optionally set `OPENAI_BASE_URL` and `OPENAI_MODEL` for custom endpoints.
- The CLI will use these credentials to authenticate with the API provider.
2. **Vertex AI:**
- Set the `GOOGLE_GENAI_USE_VERTEXAI=true` environment variable.
- **Using an API Key:** Set the `GOOGLE_API_KEY` environment variable.
- **Using Application Default Credentials (ADC):**
- Run `gcloud auth application-default login` in your environment to configure ADC.
- Ensure the `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` environment variables are set.
**Example for headless environments:**
If none of these environment variables are set in a non-interactive session, the CLI will exit with an error.
```bash
export OPENAI_API_KEY="your-api-key"
export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
# Run Qwen Code
qwen
```
If no API key is set in a non-interactive session, the CLI will exit with an error prompting you to configure authentication.

View File

@@ -17,11 +17,19 @@ Slash commands provide meta-level control over the CLI itself.
- **`save`**
- **Description:** Saves the current conversation history. You must add a `<tag>` for identifying the conversation state.
- **Usage:** `/chat save <tag>`
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
- Linux/macOS: `~/.config/google-generative-ai/checkpoints/`
- Windows: `C:\Users\<YourUsername>\AppData\Roaming\google-generative-ai\checkpoints\`
- When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints.
- **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md).
- **`resume`**
- **Description:** Resumes a conversation from a previous save.
- **Usage:** `/chat resume <tag>`
- **`list`**
- **Description:** Lists available tags for chat state resumption.
- **`delete`**
- **Description:** Deletes a saved conversation checkpoint.
- **Usage:** `/chat delete <tag>`
- **`/clear`**
- **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared.
@@ -33,6 +41,28 @@ Slash commands provide meta-level control over the CLI itself.
- **`/copy`**
- **Description:** Copies the last output produced by Qwen Code to your clipboard, for easy sharing or reuse.
- **`/directory`** (or **`/dir`**)
- **Description:** Manage workspace directories for multi-directory support.
- **Sub-commands:**
- **`add`**:
- **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well.
- **Usage:** `/directory add <path1>,<path2>`
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
- **`show`**:
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/directory`** (or **`/dir`**)
- **Description:** Manage workspace directories for multi-directory support.
- **Sub-commands:**
- **`add`**:
- **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well.
- **Usage:** `/directory add <path1>,<path2>`
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
- **`show`**:
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/editor`**
- **Description:** Open a dialog for selecting supported editors.
@@ -54,15 +84,15 @@ Slash commands provide meta-level control over the CLI itself.
- **Keyboard Shortcut:** Press **Ctrl+T** at any time to toggle between showing and hiding tool descriptions.
- **`/memory`**
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `GEMINI.md` files).
- **Description:** Manage the AI's instructional context (hierarchical memory loaded from `QWEN.md` files by default; configurable via `contextFileName`).
- **Sub-commands:**
- **`add`**:
- **Description:** Adds the following text to the AI's memory. Usage: `/memory add <text to remember>`
- **`show`**:
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all `GEMINI.md` files. This lets you inspect the instructional context being provided to the Gemini model.
- **Description:** Display the full, concatenated content of the current hierarchical memory that has been loaded from all context files (e.g., `QWEN.md`). This lets you inspect the instructional context being provided to the model.
- **`refresh`**:
- **Description:** Reload the hierarchical instructional memory from all `GEMINI.md` files found in the configured locations (global, project/ancestors, and sub-directories). This command updates the model with the latest `GEMINI.md` content.
- **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#4-geminimd-files-hierarchical-instructional-context).
- **Description:** Reload the hierarchical instructional memory from all context files (default: `QWEN.md`) found in the configured locations (global, project/ancestors, and sub-directories). This updates the model with the latest context content.
- **Note:** For more details on how context files contribute to hierarchical memory, see the [CLI Configuration documentation](./configuration.md#context-files-hierarchical-instructional-context).
- **`/restore`**
- **Description:** Restores the project files to the state they were in just before a tool was executed. This is particularly useful for undoing file edits made by a tool. If run without a tool call ID, it will list available checkpoints to restore from.
@@ -106,6 +136,9 @@ Slash commands provide meta-level control over the CLI itself.
- **Persistent setting:** Vim mode preference is saved to `~/.gemini/settings.json` and restored between sessions
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer
- **`/init`**
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
### Custom Commands
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.
@@ -234,7 +267,7 @@ Please generate a Conventional Commit message based on the following git diff:
```diff
!{git diff --staged}
````
```
"""
@@ -255,7 +288,7 @@ First, ensure the user commands directory exists, then create a `refactor` subdi
```bash
mkdir -p ~/.gemini/commands/refactor
touch ~/.gemini/commands/refactor/pure.toml
````
```
**2. Add the content to the file:**

View File

@@ -38,8 +38,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont
### Available settings in `settings.json`:
- **`contextFileName`** (string or array of strings):
- **Description:** Specifies the filename for context files (e.g., `GEMINI.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
- **Default:** `GEMINI.md`
- **Description:** Specifies the filename for context files (e.g., `QWEN.md`, `AGENTS.md`). Can be a single filename or a list of accepted filenames.
- **Default:** `QWEN.md`
- **Example:** `"contextFileName": "AGENTS.md"`
- **`bugCommand`** (object):
@@ -240,6 +240,39 @@ In addition to a project settings file, a project's `.gemini` directory can cont
}
```
- **`excludedProjectEnvVars`** (array of strings):
- **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded.
- **Default:** `["DEBUG", "DEBUG_MODE"]`
- **Example:**
```json
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
```
- **`includeDirectories`** (array of strings):
- **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. This allows you to work with files across multiple directories as if they were one. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag.
- **Default:** `[]`
- **Example:**
```json
"includeDirectories": [
"/path/to/another/project",
"../shared-library",
"~/common-utils"
]
```
- **`loadMemoryFromIncludeDirectories`** (boolean):
- **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory.
- **Default:** `false`
- **Example:**
```json
"loadMemoryFromIncludeDirectories": true
```
- **`tavilyApiKey`** (string):
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Default:** `undefined` (web search disabled)
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
### Example `settings.json`:
```json
@@ -248,6 +281,7 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"sandbox": "docker",
"toolDiscoveryCommand": "bin/get_tools",
"toolCallCommand": "bin/call_tool",
"tavilyApiKey": "$TAVILY_API_KEY",
"mcpServers": {
"mainServer": {
"command": "bin/mcp_server.py"
@@ -271,7 +305,10 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"run_shell_command": {
"tokenBudget": 100
}
}
},
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"],
"includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"],
"loadMemoryFromIncludeDirectories": true
}
```
@@ -293,6 +330,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory.
3. If still not found, it looks for `~/.env` (in the user's home directory).
**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from being loaded from project `.env` files to prevent interference with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file.
- **`GEMINI_API_KEY`** (Required):
- Your API key for the Gemini API.
- **Crucial for operation.** The CLI will not function without it.
@@ -332,6 +371,7 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`).
- **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself):
- Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting.
- **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with gemini-cli behavior. Use `.gemini/.env` files if you need to set these for gemini-cli specifically.
- **`NO_COLOR`**:
- Set to any value to disable all color output in the CLI.
- **`CLI_TITLE`**:
@@ -339,6 +379,11 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- **`CODE_ASSIST_ENDPOINT`**:
- Specifies the endpoint for the code assist server.
- This is useful for development and testing.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -387,10 +432,18 @@ Arguments passed directly when running the CLI can override other configurations
- **`--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.
- Can be specified multiple times or as comma-separated values.
- 5 directories can be added at maximum.
- Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2`
- **`--version`**:
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `gemini --tavily-api-key tvly-your-api-key-here`
## Context Files (Hierarchical Instructional Context)
@@ -398,7 +451,7 @@ While not strictly configuration for the CLI's _behavior_, context files (defaul
- **Purpose:** These Markdown files contain instructions, guidelines, or context that you want the Gemini model to be aware of during your interactions. The system is designed to manage this instructional context hierarchically.
### Example Context File Content (e.g., `GEMINI.md`)
### Example Context File Content (e.g., `QWEN.md`)
Here's a conceptual example of what a context file at the root of a TypeScript project might contain:
@@ -433,9 +486,9 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `GEMINI.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
1. **Global Context File:**
- Location: `~/.gemini/<contextFileName>` (e.g., `~/.gemini/GEMINI.md` in your user home directory).
- Location: `~/.qwen/<contextFileName>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
- Scope: Provides default instructions for all your projects.
2. **Project Root & Ancestors Context Files:**
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
@@ -444,6 +497,7 @@ This example demonstrates how you can provide general project context, specific
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with a `memoryDiscoveryMaxDirs` field in your `settings.json` file.
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt to the Gemini model. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../core/memport.md).
- **Commands for Memory Management:**
- Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context.
- Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI.
@@ -505,3 +559,5 @@ You can opt out of usage statistics collection at any time by setting the `usage
"usageStatisticsEnabled": false
}
```
Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM collection endpoint.

View File

@@ -1,28 +1,28 @@
# Gemini CLI
# Qwen Code CLI
Within Gemini CLI, `packages/cli` is the frontend for users to send and receive prompts with the Gemini AI model and its associated tools. For a general overview of Gemini CLI, see the [main documentation page](../index.md).
Within Qwen Code, `packages/cli` is the frontend for users to send and receive prompts with Qwen and other AI models and their associated tools. For a general overview of Qwen Code, see the [main documentation page](../index.md).
## Navigating this section
- **[Authentication](./authentication.md):** A guide to setting up authentication with Google's AI services.
- **[Commands](./commands.md):** A reference for Gemini CLI commands (e.g., `/help`, `/tools`, `/theme`).
- **[Configuration](./configuration.md):** A guide to tailoring Gemini CLI behavior using configuration files.
- **[Authentication](./authentication.md):** A guide to setting up authentication with Qwen OAuth and OpenAI-compatible providers.
- **[Commands](./commands.md):** A reference for Qwen Code CLI commands (e.g., `/help`, `/tools`, `/theme`).
- **[Configuration](./configuration.md):** A guide to tailoring Qwen Code CLI behavior using configuration files.
- **[Token Caching](./token-caching.md):** Optimize API costs through token caching.
- **[Themes](./themes.md)**: A guide to customizing the CLI's appearance with different themes.
- **[Tutorials](tutorials.md)**: A tutorial showing how to use Gemini CLI to automate a development task.
- **[Tutorials](tutorials.md)**: A tutorial showing how to use Qwen Code to automate a development task.
## Non-interactive mode
Gemini CLI can be run in a non-interactive mode, which is useful for scripting and automation. In this mode, you pipe input to the CLI, it executes the command, and then it exits.
Qwen Code can be run in a non-interactive mode, which is useful for scripting and automation. In this mode, you pipe input to the CLI, it executes the command, and then it exits.
The following example pipes a command to Gemini CLI from your terminal:
The following example pipes a command to Qwen Code from your terminal:
```bash
echo "What is fine tuning?" | gemini
echo "What is fine tuning?" | qwen
```
Gemini CLI executes the command and prints the output to your terminal. Note that you can achieve the same behavior by using the `--prompt` or `-p` flag. For example:
Qwen Code executes the command and prints the output to your terminal. Note that you can achieve the same behavior by using the `--prompt` or `-p` flag. For example:
```bash
gemini -p "What is fine tuning?"
qwen -p "What is fine tuning?"
```

View File

@@ -58,7 +58,11 @@ Add a `customThemes` block to your user, project, or system `settings.json` file
"AccentYellow": "#E5C07B",
"AccentRed": "#E06C75",
"Comment": "#5C6370",
"Gray": "#ABB2BF"
"Gray": "#ABB2BF",
"DiffAdded": "#A6E3A1",
"DiffRemoved": "#F38BA8",
"DiffModified": "#89B4FA",
"GradientColors": ["#4796E4", "#847ACE", "#C3677F"]
}
}
}
@@ -77,6 +81,9 @@ Add a `customThemes` block to your user, project, or system `settings.json` file
- `AccentRed`
- `Comment`
- `Gray`
- `DiffAdded` (optional, for added lines in diffs)
- `DiffRemoved` (optional, for removed lines in diffs)
- `DiffModified` (optional, for modified lines in diffs)
**Required Properties:**

View File

@@ -5,14 +5,14 @@ Gemini CLI's core package (`packages/core`) is the backend portion of Gemini CLI
## Navigating this section
- **[Core tools API](./tools-api.md):** Information on how tools are defined, registered, and used by the core.
- **[Memory Import Processor](./memport.md):** Documentation for the modular GEMINI.md import feature using @file.md syntax.
- **[Memory Import Processor](./memport.md):** Documentation for the modular QWEN.md import feature using @file.md syntax.
## Role of the core
While the `packages/cli` portion of Gemini CLI provides the user interface, `packages/core` is responsible for:
- **Gemini API interaction:** Securely communicating with the Google Gemini API, sending user prompts, and receiving model responses.
- **Prompt engineering:** Constructing effective prompts for the Gemini model, potentially incorporating conversation history, tool definitions, and instructional context from `GEMINI.md` files.
- **Prompt engineering:** Constructing effective prompts for the model, potentially incorporating conversation history, tool definitions, and instructional context from context files (e.g., `QWEN.md`).
- **Tool management & orchestration:**
- Registering available tools (e.g., file system tools, shell command execution).
- Interpreting tool use requests from the Gemini model.
@@ -48,8 +48,8 @@ The file discovery service is responsible for finding files in the project that
## Memory discovery service
The memory discovery service is responsible for finding and loading the `GEMINI.md` files that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
The memory discovery service is responsible for finding and loading the context files (default: `QWEN.md`) that provide context to the model. It searches for these files in a hierarchical manner, starting from the current working directory and moving up to the project root and the user's home directory. It also searches in subdirectories.
This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information.
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded `GEMINI.md` files.
You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and `refresh` the content of loaded context files.

View File

@@ -1,21 +1,17 @@
# Memory Import Processor
The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other markdown files using the `@file.md` syntax.
The Memory Import Processor is a feature that allows you to modularize your context files (e.g., `QWEN.md`) by importing content from other files using the `@file.md` syntax.
## Overview
This feature enables you to break down large GEMINI.md files into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
## Important Limitations
**This feature only supports `.md` (markdown) files.** Attempting to import files with other extensions (like `.txt`, `.json`, etc.) will result in a warning and the import will fail.
This feature enables you to break down large context files (e.g., `QWEN.md`) into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
## Syntax
Use the `@` symbol followed by the path to the markdown file you want to import:
Use the `@` symbol followed by the path to the file you want to import:
```markdown
# Main GEMINI.md file
# Main QWEN.md file
This is the main content.
@@ -43,7 +39,7 @@ More content here.
### Basic Import
```markdown
# My GEMINI.md
# My QWEN.md
Welcome to my project!
@@ -96,24 +92,10 @@ The `validateImportPath` function ensures that imports are only allowed from spe
### Maximum Import Depth
To prevent infinite recursion, there's a configurable maximum import depth (default: 10 levels).
To prevent infinite recursion, there's a configurable maximum import depth (default: 5 levels).
## Error Handling
### Non-MD File Attempts
If you try to import a non-markdown file, you'll see a warning:
```markdown
@./instructions.txt <!-- This will show a warning and fail -->
```
Console output:
```
[WARN] [ImportProcessor] Import processor only supports .md files. Attempting to import non-md file: ./instructions.txt. This will fail.
```
### Missing Files
If a referenced file doesn't exist, the import will fail gracefully with an error comment in the output.
@@ -122,11 +104,41 @@ If a referenced file doesn't exist, the import will fail gracefully with an erro
Permission issues or other file system errors are handled gracefully with appropriate error messages.
## Code Region Detection
The import processor uses the `marked` library to detect code blocks and inline code spans, ensuring that `@` imports inside these regions are properly ignored. This provides robust handling of nested code blocks and complex Markdown structures.
## Import Tree Structure
The processor returns an import tree that shows the hierarchy of imported files. This helps users debug problems with their context files by showing which files were read and their import relationships.
Example tree structure:
```
Memory Files
L project: QWEN.md
L a.md
L b.md
L c.md
L d.md
L e.md
L f.md
L included.md
```
The tree preserves the order that files were imported and shows the complete import chain for debugging purposes.
## Comparison to Claude Code's `/memory` (`claude.md`) Approach
Claude Code's `/memory` feature (as seen in `claude.md`) produces a flat, linear document by concatenating all included files, always marking file boundaries with clear comments and path names. It does not explicitly present the import hierarchy, but the LLM receives all file contents and paths, which is sufficient for reconstructing the hierarchy if needed.
Note: The import tree is mainly for clarity during development and has limited relevance to LLM consumption.
## API Reference
### `processImports(content, basePath, debugMode?, importState?)`
Processes import statements in GEMINI.md content.
Processes import statements in context file content.
**Parameters:**
@@ -135,7 +147,25 @@ Processes import statements in GEMINI.md content.
- `debugMode` (boolean, optional): Whether to enable debug logging (default: false)
- `importState` (ImportState, optional): State tracking for circular import prevention
**Returns:** Promise<string> - Processed content with imports resolved
**Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree
### `ProcessImportsResult`
```typescript
interface ProcessImportsResult {
content: string; // The processed content with imports resolved
importTree: MemoryFile; // Tree structure showing the import hierarchy
}
```
### `MemoryFile`
```typescript
interface MemoryFile {
path: string; // The file path
imports?: MemoryFile[]; // Direct imports, in the order they were imported
}
```
### `validateImportPath(importPath, basePath, allowedDirectories)`
@@ -149,6 +179,16 @@ Validates import paths to ensure they are safe and within allowed directories.
**Returns:** boolean - Whether the import path is valid
### `findProjectRoot(startDir)`
Finds the project root by searching for a `.git` directory upwards from the given start directory. Implemented as an **async** function using non-blocking file system APIs to avoid blocking the Node.js event loop.
**Parameters:**
- `startDir` (string): The directory to start searching from
**Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found)
## Best Practices
1. **Use descriptive file names** for imported components
@@ -161,7 +201,7 @@ Validates import paths to ensure they are safe and within allowed directories.
### Common Issues
1. **Import not working**: Check that the file exists and has a `.md` extension
1. **Import not working**: Check that the file exists and the path is correct
2. **Circular import warnings**: Review your import structure for circular references
3. **Permission errors**: Ensure the files are readable and within allowed directories
4. **Path resolution issues**: Use absolute paths if relative paths aren't resolving correctly

View File

@@ -15,9 +15,11 @@ The Gemini CLI core (`packages/core`) features a robust system for defining, reg
- `execute()`: The core method that performs the tool's action and returns a `ToolResult`.
- **`ToolResult` (`tools.ts`):** An interface defining the structure of a tool's execution outcome:
- `llmContent`: The factual string content to be included in the history sent back to the LLM for context.
- `llmContent`: The factual content to be included in the history sent back to the LLM for context. This can be a simple string or a `PartListUnion` (an array of `Part` objects and strings) for rich content.
- `returnDisplay`: A user-friendly string (often Markdown) or a special object (like `FileDiff`) for display in the CLI.
- **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content.
- **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for:
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`).
- **Discovering Tools:** It can also discover tools dynamically:

View File

@@ -28,15 +28,49 @@ The `gemini-extension.json` file contains the configuration for the extension. T
"command": "node my-server.js"
}
},
"contextFileName": "GEMINI.md",
"contextFileName": "QWEN.md",
"excludeTools": ["run_shell_command"]
}
```
- `name`: The name of the extension. This is used to uniquely identify the extension. This should match the name of your extension directory.
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
- `version`: The version of the extension.
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
## Extension Commands
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
### Example
An extension named `gcp` with the following structure:
```
.gemini/extensions/gcp/
├── gemini-extension.json
└── commands/
├── deploy.toml
└── gcs/
└── sync.toml
```
Would provide these commands:
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
### Conflict Resolution
Extension commands have the lowest precedence. When a conflict occurs with user or project commands:
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
2. **With conflict**: Extension command is renamed with the extension prefix (e.g., `/gcp.deploy`)
For example, if both a user and the `gcp` extension define a `deploy` command:
- `/deploy` - Executes the user's deploy command
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag)

59
docs/gemini-ignore.md Normal file
View File

@@ -0,0 +1,59 @@
# Ignoring Files
This document provides an overview of the Gemini Ignore (`.geminiignore`) feature of the Gemini CLI.
The Gemini CLI includes the ability to automatically ignore files, similar to `.gitignore` (used by Git) and `.aiexclude` (used by Gemini Code Assist). Adding paths to your `.geminiignore` file will exclude them from tools that support this feature, although they will still be visible to other services (such as Git).
## How it works
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.
For the most part, `.geminiignore` follows the conventions of `.gitignore` files:
- Blank lines and lines starting with `#` are ignored.
- Standard glob patterns are supported (such as `*`, `?`, and `[]`).
- Putting a `/` at the end will only match directories.
- Putting a `/` at the beginning anchors the path relative to the `.geminiignore` file.
- `!` negates a pattern.
You can update your `.geminiignore` file at any time. To apply the changes, you must restart your Gemini CLI session.
## How to use `.geminiignore`
To enable `.geminiignore`:
1. Create a file named `.geminiignore` in the root of your project directory.
To add a file or directory to `.geminiignore`:
1. Open your `.geminiignore` file.
2. Add the path or file you want to ignore, for example: `/archive/` or `apikeys.txt`.
### `.geminiignore` examples
You can use `.geminiignore` to ignore directories and files:
```
# Exclude your /packages/ directory and all subdirectories
/packages/
# Exclude your apikeys.txt file
apikeys.txt
```
You can use wildcards in your `.geminiignore` file with `*`:
```
# Exclude all .md files
*.md
```
Finally, you can exclude files and directories from exclusion with `!`:
```
# Exclude all .md files except README.md
*.md
!README.md
```
To remove paths from your `.geminiignore` file, delete the relevant lines.

View File

@@ -28,7 +28,7 @@ This documentation is organized into the following sections:
- **[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` tool.
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `google_web_search` tool.
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `web_search` tool.
- **[Memory Tool](./tools/memory.md):** Documentation for the `save_memory` tool.
- **[Contributing & Development Guide](../CONTRIBUTING.md):** Information for contributors and developers, including setup, building, testing, and coding conventions.
- **[NPM Workspaces and Publishing](./npm.md):** Details on how the project's packages are managed and published.

View File

@@ -0,0 +1,84 @@
# Automation and Triage Processes
This document provides a detailed overview of the automated processes we use to manage and triage issues and pull requests. Our goal is to provide prompt feedback and ensure that contributions are reviewed and integrated efficiently. Understanding this automation will help you as a contributor know what to expect and how to best interact with our repository bots.
## Guiding Principle: Issues and Pull Requests
First and foremost, almost every Pull Request (PR) should be linked to a corresponding Issue. The issue describes the "what" and the "why" (the bug or feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle.
---
## Detailed Automation Workflows
Here is a breakdown of the specific automation workflows that run in our repository.
### 1. When you open an Issue: `Automated Issue Triage`
This is the first bot you will interact with when you create an issue. Its job is to perform an initial analysis and apply the correct labels.
- **Workflow File**: `.github/workflows/gemini-automated-issue-triage.yml`
- **When it runs**: Immediately after an issue is created or reopened.
- **What it does**:
- It uses a Gemini model to analyze the issue's title and body against a detailed set of guidelines.
- **Applies one `area/*` label**: Categorizes the issue into a functional area of the project (e.g., `area/ux`, `area/models`, `area/platform`).
- **Applies one `kind/*` label**: Identifies the type of issue (e.g., `kind/bug`, `kind/enhancement`, `kind/question`).
- **Applies one `priority/*` label**: Assigns a priority from P0 (critical) to P3 (low) based on the described impact.
- **May apply `status/need-information`**: If the issue lacks critical details (like logs or reproduction steps), it will be flagged for more information.
- **May apply `status/need-retesting`**: If the issue references a CLI version that is more than six versions old, it will be flagged for retesting on a current version.
- **What you should do**:
- Fill out the issue template as completely as possible. The more detail you provide, the more accurate the triage will be.
- If the `status/need-information` label is added, please provide the requested details in a comment.
### 2. When you open a Pull Request: `Continuous Integration (CI)`
This workflow ensures that all changes meet our quality standards before they can be merged.
- **Workflow File**: `.github/workflows/ci.yml`
- **When it runs**: On every push to a pull request.
- **What it does**:
- **Lint**: Checks that your code adheres to our project's formatting and style rules.
- **Test**: Runs our full suite of automated tests across macOS, Windows, and Linux, and on multiple Node.js versions. This is the most time-consuming part of the CI process.
- **Post Coverage Comment**: After all tests have successfully passed, a bot will post a comment on your PR. This comment provides a summary of how well your changes are covered by tests.
- **What you should do**:
- Ensure all CI checks pass. A green checkmark ✅ will appear next to your commit when everything is successful.
- If a check fails (a red "X" ❌), click the "Details" link next to the failed check to view the logs, identify the problem, and push a fix.
### 3. Ongoing Triage for Pull Requests: `PR Auditing and Label Sync`
This workflow runs periodically to ensure all open PRs are correctly linked to issues and have consistent labels.
- **Workflow File**: `.github/workflows/gemini-scheduled-pr-triage.yml`
- **When it runs**: Every 15 minutes on all open pull requests.
- **What it does**:
- **Checks for a linked issue**: The bot scans your PR description for a keyword that links it to an issue (e.g., `Fixes #123`, `Closes #456`).
- **Adds `status/need-issue`**: If no linked issue is found, the bot will add the `status/need-issue` label to your PR. This is a clear signal that an issue needs to be created and linked.
- **Synchronizes labels**: If an issue _is_ linked, the bot ensures the PR's labels perfectly match the issue's labels. It will add any missing labels and remove any that don't belong, and it will remove the `status/need-issue` label if it was present.
- **What you should do**:
- **Always link your PR to an issue.** This is the most important step. Add a line like `Resolves #<issue-number>` to your PR description.
- This will ensure your PR is correctly categorized and moves through the review process smoothly.
### 4. Ongoing Triage for Issues: `Scheduled Issue Triage`
This is a fallback workflow to ensure that no issue gets missed by the triage process.
- **Workflow File**: `.github/workflows/gemini-scheduled-issue-triage.yml`
- **When it runs**: Every hour on all open issues.
- **What it does**:
- It actively seeks out issues that either have no labels at all or still have the `status/need-triage` label.
- It then triggers the same powerful Gemini-based analysis as the initial triage bot to apply the correct labels.
- **What you should do**:
- You typically don't need to do anything. This workflow is a safety net to ensure every issue is eventually categorized, even if the initial triage fails.
### 5. Release Automation
This workflow handles the process of packaging and publishing new versions of the Gemini CLI.
- **Workflow File**: `.github/workflows/release.yml`
- **When it runs**: On a daily schedule for "nightly" releases, and manually for official patch/minor releases.
- **What it does**:
- Automatically builds the project, bumps the version numbers, and publishes the packages to npm.
- Creates a corresponding release on GitHub with generated release notes.
- **What you should do**:
- As a contributor, you don't need to do anything for this process. You can be confident that once your PR is merged into the `main` branch, your changes will be included in the very next nightly release.
We hope this detailed overview is helpful. If you have any questions about our automation or processes, please don't hesitate to ask!

View File

@@ -0,0 +1,62 @@
# Gemini CLI Keyboard Shortcuts
This document lists the available keyboard shortcuts in the Gemini CLI.
## General
| Shortcut | Description |
| -------- | --------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Exit the application. Press twice to confirm. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+O` | Toggle the display of the debug console. |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+T` | Toggle the display of tool descriptions. |
| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. |
## Input Prompt
| 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 |
| `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+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+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. |
## Suggestions
| Shortcut | Description |
| --------------- | -------------------------------------- |
| `Down Arrow` | Navigate down through the suggestions. |
| `Tab` / `Enter` | Accept the selected suggestion. |
| `Up Arrow` | Navigate up through the suggestions. |
## Radio Button Select
| 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. |

View File

@@ -77,6 +77,24 @@ Built-in profiles (set via `SEATBELT_PROFILE` env var):
- `restrictive-open`: Strict restrictions, network allowed
- `restrictive-closed`: Maximum restrictions
### Custom Sandbox Flags
For container-based sandboxing, you can inject custom flags into the `docker` or `podman` command using the `SANDBOX_FLAGS` environment variable. This is useful for advanced configurations, such as disabling security features for specific use cases.
**Example (Podman)**:
To disable SELinux labeling for volume mounts, you can set the following:
```bash
export SANDBOX_FLAGS="--security-opt label=disable"
```
Multiple flags can be provided as a space-separated string:
```bash
export SANDBOX_FLAGS="--flag1 --flag2=value"
```
## Linux UID/GID handling
The sandbox automatically handles user permissions on Linux. Override these permissions with:
@@ -111,6 +129,8 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping
DEBUG=1 gemini -s -p "debug command"
```
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
### Inspect sandbox
```bash

View File

@@ -209,6 +209,11 @@ Logs are timestamped records of specific events. The following events are logged
- **Attributes**:
- `auth_type`
- `gemini_cli.slash_command`: This event occurs when a user executes a slash command.
- **Attributes**:
- `command` (string)
- `subcommand` (string, if applicable)
### Metrics
Metrics are numerical measurements of behavior over time. The following metrics are collected for Gemini CLI:

View File

@@ -169,6 +169,7 @@ Use the `/mcp auth` command to manage OAuth authentication:
- **`scopes`** (string[]): Required OAuth scopes
- **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`)
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
- **`audiences`** (string[]): Audiences the token is valid for
#### Token Management
@@ -570,3 +571,120 @@ The MCP integration tracks several states:
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use.
## Returning Rich Content from Tools
MCP tools are not limited to returning simple text. You can return rich, multi-part content, including text, images, audio, and other binary data in a single tool response. This allows you to build powerful tools that can provide diverse information to the model in a single turn.
All data returned from the tool is processed and sent to the model as context for its next generation, enabling it to reason about or summarize the provided information.
### How It Works
To return rich content, your tool's response must adhere to the MCP specification for a [`CallToolResult`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). The `content` field of the result should be an array of `ContentBlock` objects. The Gemini CLI will correctly process this array, separating text from binary data and packaging it for the model.
You can mix and match different content block types in the `content` array. The supported block types include:
- `text`
- `image`
- `audio`
- `resource` (embedded content)
- `resource_link`
### Example: Returning Text and an Image
Here is an example of a valid JSON response from an MCP tool that returns both a text description and an image:
```json
{
"content": [
{
"type": "text",
"text": "Here is the logo you requested."
},
{
"type": "image",
"data": "BASE64_ENCODED_IMAGE_DATA_HERE",
"mimeType": "image/png"
},
{
"type": "text",
"text": "The logo was created in 2025."
}
]
}
```
When the Gemini CLI receives this response, it will:
1. Extract all the text and combine it into a single `functionResponse` part for the model.
2. Present the image data as a separate `inlineData` part.
3. Provide a clean, user-friendly summary in the CLI, indicating that both text and an image were received.
This enables you to build sophisticated tools that can provide rich, multi-modal context to the Gemini model.
## MCP Prompts as Slash Commands
In addition to tools, MCP servers can expose predefined prompts that can be executed as slash commands within the Gemini CLI. This allows you to create shortcuts for common or complex queries that can be easily invoked by name.
### Defining Prompts on the Server
Here's a small example of a stdio MCP server that defines prompts:
```ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({
name: 'prompt-server',
version: '1.0.0',
});
server.registerPrompt(
'poem-writer',
{
title: 'Poem Writer',
description: 'Write a nice haiku',
argsSchema: { title: z.string(), mood: z.string().optional() },
},
({ title, mood }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `,
},
},
],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);
```
This can be included in `settings.json` under `mcpServers` with:
```json
"nodeServer": {
"command": "node",
"args": ["filename.ts"],
}
```
### Invoking Prompts
Once a prompt is discovered, you can invoke it using its name as a slash command. The CLI will automatically handle parsing arguments.
```bash
/poem-writer --title="Gemini CLI" --mood="reverent"
```
or, using positional arguments:
```bash
/poem-writer "Gemini CLI" reverent
```
When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows.

View File

@@ -4,7 +4,7 @@ This document describes the `save_memory` tool for the Gemini CLI.
## Description
Use `save_memory` to save and recall information across your Gemini CLI sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
Use `save_memory` to save and recall information across your Qwen Code sessions. With `save_memory`, you can direct the CLI to remember key details across sessions, providing personalized and directed assistance.
### Arguments
@@ -14,9 +14,9 @@ Use `save_memory` to save and recall information across your Gemini CLI sessions
## How to use `save_memory` with the Gemini CLI
The tool appends the provided `fact` to a special `GEMINI.md` file located in the user's home directory (`~/.gemini/GEMINI.md`). This file can be configured to have a different name.
The tool appends the provided `fact` to your context file in the user's home directory (`~/.qwen/QWEN.md` by default). This filename can be configured via `contextFileName`.
Once added, the facts are stored under a `## Gemini Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
Once added, the facts are stored under a `## Qwen Added Memories` section. This file is loaded as context in subsequent sessions, allowing the CLI to recall the saved information.
Usage:

View File

@@ -11,11 +11,13 @@ Use `read_many_files` to read content from multiple files specified by paths or
`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"]`).
- `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`.
@@ -50,7 +52,7 @@ Read the main README, all Markdown files in the `docs` directory, and a specific
read_many_files(paths=["README.md", "docs/**/*.md", "assets/logo.png"], exclude=["docs/OLD_README.md"])
```
Read all JavaScript files but explicitly including test files and all JPEGs in an `images` folder:
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)

View File

@@ -137,6 +137,5 @@ To block all shell commands, add the `run_shell_command` wildcard to `excludeToo
## Security Note for `excludeTools`
Command-specific restrictions in
`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
Command-specific restrictions in `excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
that can be executed.

View File

@@ -4,24 +4,25 @@ This document describes the `web_fetch` tool for the Gemini CLI.
## Description
Use `web_fetch` to summarize, compare, or extract information from web pages. The `web_fetch` tool processes content from one or more URLs (up to 20) embedded in a prompt. `web_fetch` takes a natural language prompt and returns a generated response.
Use `web_fetch` to fetch content from a specified URL and process it using an AI model. The tool takes a URL and a prompt as input, fetches the URL content, converts HTML to markdown, and processes the content with the prompt using a small, fast model.
### Arguments
`web_fetch` takes one argument:
`web_fetch` takes two arguments:
- `prompt` (string, required): A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content. For example: `"Summarize https://example.com/article and extract key points from https://another.com/data"`. The prompt must contain at least one URL starting with `http://` or `https://`.
- `url` (string, required): The URL to fetch content from. Must be a fully-formed valid URL starting with `http://` or `https://`.
- `prompt` (string, required): The prompt describing what information you want to extract from the page content.
## How to use `web_fetch` with the Gemini CLI
To use `web_fetch` with the Gemini CLI, provide a natural language prompt that contains URLs. The tool will ask for confirmation before fetching any URLs. Once confirmed, the tool will process URLs through Gemini API's `urlContext`.
To use `web_fetch` with the Gemini CLI, provide a URL and a prompt describing what you want to extract from that URL. The tool will ask for confirmation before fetching the URL. Once confirmed, the tool will fetch the content directly and process it using an AI model.
If the Gemini API cannot access the URL, the tool will fall back to fetching content directly from the local machine. The tool will format the response, including source attribution and citations where possible. The tool will then provide the response to the user.
The tool automatically converts HTML to text, handles GitHub blob URLs (converting them to raw URLs), and upgrades HTTP URLs to HTTPS for security.
Usage:
```
web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
web_fetch(url="https://example.com", prompt="Summarize the main points of this article")
```
## `web_fetch` examples
@@ -29,16 +30,25 @@ web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
Summarize a single article:
```
web_fetch(prompt="Can you summarize the main points of https://example.com/news/latest")
web_fetch(url="https://example.com/news/latest", prompt="Can you summarize the main points of this article?")
```
Compare two articles:
Extract specific information:
```
web_fetch(prompt="What are the differences in the conclusions of these two papers: https://arxiv.org/abs/2401.0001 and https://arxiv.org/abs/2401.0002?")
web_fetch(url="https://arxiv.org/abs/2401.0001", prompt="What are the key findings and methodology described in this paper?")
```
Analyze GitHub documentation:
```
web_fetch(url="https://github.com/google/gemini-react/blob/main/README.md", prompt="What are the installation steps and main features?")
```
## Important notes
- **URL processing:** `web_fetch` relies on the Gemini API's ability to access and process the given URLs.
- **Single URL processing:** `web_fetch` processes one URL at a time. To analyze multiple URLs, make separate calls to the tool.
- **URL format:** The tool automatically upgrades HTTP URLs to HTTPS and converts GitHub blob URLs to raw format for better content access.
- **Content processing:** The tool fetches content directly and processes it using an AI model, converting HTML to readable text format.
- **Output quality:** The quality of the output will depend on the clarity of the instructions in the prompt.
- **MCP tools:** If an MCP-provided web fetch tool is available (starting with "mcp\_\_"), prefer using that tool as it may have fewer restrictions.

View File

@@ -1,36 +1,43 @@
# Web Search Tool (`google_web_search`)
# Web Search Tool (`web_search`)
This document describes the `google_web_search` tool.
This document describes the `web_search` tool.
## Description
Use `google_web_search` to perform a web search using Google Search via the Gemini API. The `google_web_search` tool returns a summary of web results with sources.
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
### Arguments
`google_web_search` takes one argument:
`web_search` takes one argument:
- `query` (string, required): The search query.
## How to use `google_web_search` with the Gemini CLI
## How to use `web_search`
The `google_web_search` tool sends a query to the Gemini API, which then performs a web search. `google_web_search` will return a generated response based on the search results, including citations and sources.
`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods:
1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json`
2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file
3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI
If the key is not configured, the tool will be disabled and skipped.
Usage:
```
google_web_search(query="Your query goes here.")
web_search(query="Your query goes here.")
```
## `google_web_search` examples
## `web_search` examples
Get information on a topic:
```
google_web_search(query="latest advancements in AI-powered code generation")
web_search(query="latest advancements in AI-powered code generation")
```
## Important notes
- **Response returned:** The `google_web_search` tool returns a processed summary, not a raw list of search results.
- **Citations:** The response includes citations to the sources used to generate the summary.
- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links.
- **Citations:** Source links are appended as a numbered list.
- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered.

View File

@@ -63,6 +63,8 @@ You may opt-out from sending Usage Statistics to Google by following the instruc
Whether your code, including prompts and answers, is used to train Google's models depends on the type of authentication method you use and your account type.
By default (if you have not opted out):
- **Google account with Gemini Code Assist for Individuals**: Yes. When you use your personal Google account, the [Gemini Code Assist Privacy Notice for Individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) applies. Under this notice,
your **prompts, answers, and related code are collected** and may be used to improve Google's products, including for model training.
- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: No. For these accounts, your data is governed by the [Gemini Code Assist Privacy Notices](https://cloud.google.com/gemini/docs/codeassist/security-privacy-compliance#standard_and_enterprise_data_protection_and_privacy) terms, which treat your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
@@ -71,17 +73,21 @@ Whether your code, including prompts and answers, is used to train Google's mode
- **Paid services**: No. When you use the Gemini API key via the Gemini Developer API with a paid service, the [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services) terms apply, which treats your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
- **Gemini API key via the Vertex AI GenAI API**: No. For these accounts, your data is governed by the [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice) terms, which treat your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
For more information about opting out, refer to the next question.
### 2. What are Usage Statistics and what does the opt-out control?
The **Usage Statistics** setting is the single control for all optional data collection in the Gemini CLI.
The data it collects depends on your account and authentication type:
- **Google account with Gemini Code Assist for Individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers** for model improvement.
- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: This setting only controls the collection of anonymous telemetry. Your prompts and answers are never collected, regardless of this setting.
- **Google account with Gemini Code Assist for Individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers, including code,** for model improvement.
- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: This setting only controls the collection of anonymous telemetry. Your prompts and answers, including code, are never collected, regardless of this setting.
- **Gemini API key via the Gemini Developer API**:
**Unpaid services**: When enabled, this setting allows Google to collect both anonymous telemetry (like commands run and performance metrics) and **your prompts and answers** for model improvement. When disabled we will use your data as described in [How Google Uses Your Data](https://ai.google.dev/gemini-api/terms#data-use-unpaid).
**Unpaid services**: When enabled, this setting allows Google to collect both anonymous telemetry (like commands run and performance metrics) and **your prompts and answers, including code,** for model improvement. When disabled we will use your data as described in [How Google Uses Your Data](https://ai.google.dev/gemini-api/terms#data-use-unpaid).
**Paid services**: This setting only controls the collection of anonymous telemetry. Google logs prompts and responses for a limited period of time, solely for the purpose of detecting violations of the Prohibited Use Policy and any required legal or regulatory disclosures.
- **Gemini API key via the Vertex AI GenAI API:** This setting only controls the collection of anonymous telemetry. Your prompts and answers are never collected, regardless of this setting.
- **Gemini API key via the Vertex AI GenAI API:** This setting only controls the collection of anonymous telemetry. Your prompts and answers, including code, are never collected, regardless of this setting.
Please refer to the Privacy Notice that applies to your authentication method for more information about what data is collected and how this data is used.
You can disable Usage Statistics for any account type by following the instructions in the [Usage Statistics Configuration](./cli/configuration.md#usage-statistics) documentation.

View File

@@ -1,28 +1,38 @@
# Troubleshooting Guide
# Troubleshooting guide
This guide provides solutions to common issues and debugging tips.
This guide provides solutions to common issues and debugging tips, including topics on:
## Authentication
- Authentication or login errors
- Frequently asked questions (FAQs)
- Debugging tips
- Existing GitHub Issues similar to yours or creating new Issues
## Authentication or login errors
- **Error: `Failed to login. Message: Request contains an invalid argument`**
- Users with Google Workspace accounts, or users with Google Cloud accounts
- Users with Google Workspace accounts or Google Cloud accounts
associated with their Gmail accounts may not be able to activate the free
tier of the Google Code Assist plan.
- For Google Cloud accounts, you can work around this by setting
`GOOGLE_CLOUD_PROJECT` to your project ID.
- You can also grab an API key from [AI Studio](https://aistudio.google.com/app/apikey), which also includes a
- Alternatively, you can obtain the Gemini API key from
[Google AI Studio](http://aistudio.google.com/app/apikey), which also includes a
separate free tier.
## Frequently asked questions (FAQs)
- **Q: How do I update Gemini CLI to the latest version?**
- A: If installed globally via npm, update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`. If run from source, pull the latest changes from the repository and rebuild using `npm run build`.
- A: If you installed it globally via `npm`, update it using the command `npm install -g @google/gemini-cli@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
- **Q: Where are Gemini CLI configuration files stored?**
- A: The CLI configuration is stored within two `settings.json` files: one in your home directory and one in your project's root directory. In both locations, `settings.json` is found in the `.gemini/` folder. Refer to [CLI Configuration](./cli/configuration.md) for more details.
- **Q: Where are the Gemini CLI configuration or settings files stored?**
- A: The Gemini CLI configuration is stored in two `settings.json` files:
1. In your home directory: `~/.gemini/settings.json`.
2. In your project's root directory: `./.gemini/settings.json`.
Refer to [Gemini CLI Configuration](./cli/configuration.md) for more details.
- **Q: Why don't I see cached token counts in my stats output?**
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Vertex AI) but not for OAuth users (Google Personal/Enterprise accounts) at this time, as the Code Assist API does not support cached content creation. You can still view your total token usage with the `/stats` command.
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Gemini Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command in Gemini CLI.
## Common error messages and solutions
@@ -31,28 +41,34 @@ This guide provides solutions to common issues and debugging tips.
- **Solution:**
Either stop the other process that is using the port or configure the MCP server to use a different port.
- **Error: Command not found (when attempting to run Gemini CLI).**
- **Cause:** Gemini CLI is not correctly installed or not in your system's PATH.
- **Error: Command not found (when attempting to run Gemini CLI with `gemini`).**
- **Cause:** Gemini CLI is not correctly installed or it is not in your system's `PATH`.
- **Solution:**
1. Ensure Gemini CLI installation was successful.
2. If installed globally, check that your npm global binary directory is in your PATH.
3. If running from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`).
The update depends on how you installed Gemini CLI:
- If you installed `gemini` globally, check that your `npm` global binary directory is in your `PATH`. You can update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`.
- If you are running `gemini` from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`). To update Gemini CLI, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
- **Error: `MODULE_NOT_FOUND` or import errors.**
- **Cause:** Dependencies are not installed correctly, or the project hasn't been built.
- **Solution:**
1. Run `npm install` to ensure all dependencies are present.
2. Run `npm run build` to compile the project.
3. Verify that the build completed successfully with `npm run start`.
- **Error: "Operation not permitted", "Permission denied", or similar.**
- **Cause:** If sandboxing is enabled, then the application is likely attempting an operation restricted by your sandbox, such as writing outside the project directory or system temp directory.
- **Solution:** See [Sandboxing](./cli/configuration.md#sandboxing) for more information, including how to customize your sandbox configuration.
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory.
- **Solution:** Refer to the [Configuration: Sandboxing](./cli/configuration.md#sandboxing) documentation for more information, including how to customize your sandbox configuration.
- **CLI is not interactive in "CI" environments**
- **Issue:** The CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode.
- **Gemini CLI is not running in interactive mode in "CI" environments**
- **Issue:** The Gemini CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the Gemini CLI from starting in its interactive mode.
- **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN gemini`
- **DEBUG mode not working from project .env file**
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for gemini-cli.
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior.
- **Solution:** Use a `.gemini/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables.
## Debugging Tips
- **CLI debugging:**
@@ -67,9 +83,11 @@ This guide provides solutions to common issues and debugging tips.
- **Tool issues:**
- If a specific tool is failing, try to isolate the issue by running the simplest possible version of the command or operation the tool performs.
- For `run_shell_command`, check that the command works directly in your shell first.
- For file system tools, double-check paths and permissions.
- For _file system tools_, verify that paths are correct and check the permissions.
- **Pre-flight checks:**
- Always run `npm run preflight` before committing code. This can catch many common issues related to formatting, linting, and type errors.
If you encounter an issue not covered here, consider searching the project's issue tracker on GitHub or reporting a new issue with detailed information.
## Existing GitHub Issues similar to yours or creating new Issues
If you encounter an issue that was not covered here in this _Troubleshooting guide_, consider searching the Gemini CLI [Issue tracker on GitHub](https://github.com/google-gemini/gemini-cli/issues). If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome!

View File

@@ -34,6 +34,7 @@ export default tseslint.config(
'packages/server/dist/**',
'packages/vscode-ide-companion/dist/**',
'bundle/**',
'package/bundle/**',
],
},
eslint.configs.recommended,
@@ -150,24 +151,6 @@ export default tseslint.config(
'default-case': 'error',
},
},
{
files: ['./**/*.{tsx,ts,js}'],
plugins: {
'license-header': licenseHeader,
},
rules: {
'license-header/header': [
'error',
[
'/**',
' * @license',
' * Copyright 2025 Google LLC',
' * SPDX-License-Identifier: Apache-2.0',
' */',
],
],
},
},
// extra settings for scripts that we run directly with node
{
files: ['./scripts/**/*.js', 'esbuild.config.js'],
@@ -203,6 +186,21 @@ export default tseslint.config(
'@typescript-eslint/no-require-imports': 'off',
},
},
// extra settings for scripts that we run directly with node
{
files: ['packages/vscode-ide-companion/scripts/**/*.js'],
languageOptions: {
globals: {
...globals.node,
process: 'readonly',
console: 'readonly',
},
},
rules: {
'no-restricted-syntax': 'off',
'@typescript-eslint/no-require-imports': 'off',
},
},
// Prettier config must be last
prettierConfig,
// extra settings for scripts that we run directly with node

View File

@@ -6,25 +6,84 @@
import { strict as assert } from 'assert';
import { test } from 'node:test';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('reads a file', (t) => {
test('should be able to read a file', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to read a file');
rig.createFile('test.txt', 'hello world');
const output = rig.run(`read the file name test.txt`);
const result = await rig.run(
`read the file test.txt and show me its contents`,
);
assert.ok(output.toLowerCase().includes('hello'));
const foundToolCall = await rig.waitForToolCall('read_file');
// Add debugging information
if (!foundToolCall || !result.includes('hello world')) {
printDebugInfo(rig, result, {
'Found tool call': foundToolCall,
'Contains hello world': result.includes('hello world'),
});
}
assert.ok(foundToolCall, 'Expected to find a read_file tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'hello world', 'File read test');
});
test('writes a file', (t) => {
test('should be able to write a file', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to write a file');
rig.createFile('test.txt', '');
rig.run(`edit test.txt to have a hello world message`);
const result = await rig.run(`edit test.txt to have a hello world message`);
// Accept multiple valid tools for editing files
const foundToolCall = await rig.waitForAnyToolCall([
'write_file',
'edit',
'replace',
]);
// Add debugging information
if (!foundToolCall) {
printDebugInfo(rig, result);
}
assert.ok(
foundToolCall,
'Expected to find a write_file, edit, or replace tool call',
);
// Validate model output - will throw if no output
validateModelOutput(result, null, 'File write test');
const fileContent = rig.readFile('test.txt');
assert.ok(fileContent.toLowerCase().includes('hello'));
// Add debugging for file content
if (!fileContent.toLowerCase().includes('hello')) {
const writeCalls = rig
.readToolLogs()
.filter((t) => t.toolRequest.name === 'write_file')
.map((t) => t.toolRequest.args);
printDebugInfo(rig, result, {
'File content mismatch': true,
'Expected to contain': 'hello',
'Actual content': fileContent,
'Write tool calls': JSON.stringify(writeCalls),
});
}
assert.ok(
fileContent.toLowerCase().includes('hello'),
'Expected file to contain hello',
);
// Log success info if verbose
if (process.env.VERBOSE === 'true') {
console.log('File written successfully with hello message.');
}
});

View File

@@ -1,19 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
test('should be able to search the web', async (t) => {
const rig = new TestRig();
rig.setup(t.name);
const prompt = `what planet do we live on`;
const result = await rig.run(prompt);
assert.ok(result.toLowerCase().includes('earth'));
});

View File

@@ -6,19 +6,57 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
import { existsSync } from 'fs';
import { join } from 'path';
test('should be able to list a directory', async (t) => {
test('should be able to list a directory', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to list a directory');
rig.createFile('file1.txt', 'file 1 content');
rig.mkdir('subdir');
rig.sync();
const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`;
const result = rig.run(prompt);
// Poll for filesystem changes to propagate in containers
await rig.poll(
() => {
// Check if the files exist in the test directory
const file1Path = join(rig.testDir, 'file1.txt');
const subdirPath = join(rig.testDir, 'subdir');
return existsSync(file1Path) && existsSync(subdirPath);
},
1000, // 1 second max wait
50, // check every 50ms
);
const lines = result.split('\n').filter((line) => line.trim() !== '');
assert.ok(lines.some((line) => line.includes('file1.txt')));
assert.ok(lines.some((line) => line.includes('subdir')));
const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`;
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('list_directory');
// Add debugging information
if (
!foundToolCall ||
!result.includes('file1.txt') ||
!result.includes('subdir')
) {
const allTools = printDebugInfo(rig, result, {
'Found tool call': foundToolCall,
'Contains file1.txt': result.includes('file1.txt'),
'Contains subdir': result.includes('subdir'),
});
console.error(
'List directory calls:',
allTools
.filter((t) => t.toolRequest.name === 'list_directory')
.map((t) => t.toolRequest.args),
);
}
assert.ok(foundToolCall, 'Expected to find a list_directory tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, ['file1.txt', 'subdir'], 'List directory test');
});

View File

@@ -6,17 +6,45 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test.skip('should be able to read multiple files', async (t) => {
test('should be able to read multiple files', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to read multiple files');
rig.createFile('file1.txt', 'file 1 content');
rig.createFile('file2.txt', 'file 2 content');
const prompt = `Read the files in this directory, list them and print them to the screen`;
const prompt = `Please use read_many_files to read file1.txt and file2.txt and show me what's in them`;
const result = await rig.run(prompt);
assert.ok(result.includes('file 1 content'));
assert.ok(result.includes('file 2 content'));
// Check for either read_many_files or multiple read_file calls
const allTools = rig.readToolLogs();
const readManyFilesCall = await rig.waitForToolCall('read_many_files');
const readFileCalls = allTools.filter(
(t) => t.toolRequest.name === 'read_file',
);
// Accept either read_many_files OR at least 2 read_file calls
const foundValidPattern = readManyFilesCall || readFileCalls.length >= 2;
// Add debugging information
if (!foundValidPattern) {
printDebugInfo(rig, result, {
'read_many_files called': readManyFilesCall,
'read_file calls': readFileCalls.length,
});
}
assert.ok(
foundValidPattern,
'Expected to find either read_many_files or multiple read_file tool calls',
);
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(
result,
['file 1 content', 'file 2 content'],
'Read many files test',
);
});

View File

@@ -6,17 +6,61 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to replace content in a file', async (t) => {
test('should be able to replace content in a file', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to replace content in a file');
const fileName = 'file_to_replace.txt';
rig.createFile(fileName, 'original content');
const originalContent = 'original content';
const expectedContent = 'replaced content';
rig.createFile(fileName, originalContent);
const prompt = `Can you replace 'original' with 'replaced' in the file 'file_to_replace.txt'`;
await rig.run(prompt);
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('replace');
// Add debugging information
if (!foundToolCall) {
printDebugInfo(rig, result);
}
assert.ok(foundToolCall, 'Expected to find a replace tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(
result,
['replaced', 'file_to_replace.txt'],
'Replace content test',
);
const newFileContent = rig.readFile(fileName);
assert.strictEqual(newFileContent, 'replaced content');
// Add debugging for file content
if (newFileContent !== expectedContent) {
console.error('File content mismatch - Debug info:');
console.error('Expected:', expectedContent);
console.error('Actual:', newFileContent);
console.error(
'Tool calls:',
rig.readToolLogs().map((t) => ({
name: t.toolRequest.name,
args: t.toolRequest.args,
})),
);
}
assert.strictEqual(
newFileContent,
expectedContent,
'File content should be updated correctly',
);
// Log success info if verbose
if (process.env.VERBOSE === 'true') {
console.log('File replaced successfully. New content:', newFileContent);
}
});

View File

@@ -101,6 +101,7 @@ async function main() {
KEEP_OUTPUT: keepOutput.toString(),
VERBOSE: verbose.toString(),
TEST_FILE_NAME: testFileName,
TELEMETRY_LOG_FILE: join(testFileDir, 'telemetry.log'),
},
});

View File

@@ -6,26 +6,58 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to run a shell command', async (t) => {
test('should be able to run a shell command', async () => {
const rig = new TestRig();
rig.setup(t.name);
rig.createFile('blah.txt', 'some content');
await rig.setup('should be able to run a shell command');
const prompt = `Can you use ls to list the contexts of the current folder`;
const result = rig.run(prompt);
const prompt = `Please run the command "echo hello-world" and show me the output`;
assert.ok(result.includes('blah.txt'));
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('run_shell_command');
// Add debugging information
if (!foundToolCall || !result.includes('hello-world')) {
printDebugInfo(rig, result, {
'Found tool call': foundToolCall,
'Contains hello-world': result.includes('hello-world'),
});
}
assert.ok(foundToolCall, 'Expected to find a run_shell_command tool call');
// Validate model output - will throw if no output, warn if missing expected content
// Model often reports exit code instead of showing output
validateModelOutput(
result,
['hello-world', 'exit code 0'],
'Shell command test',
);
});
test('should be able to run a shell command via stdin', async (t) => {
test('should be able to run a shell command via stdin', async () => {
const rig = new TestRig();
rig.setup(t.name);
rig.createFile('blah.txt', 'some content');
await rig.setup('should be able to run a shell command via stdin');
const prompt = `Can you use ls to list the contexts of the current folder`;
const result = rig.run({ stdin: prompt });
const prompt = `Please run the command "echo test-stdin" and show me what it outputs`;
assert.ok(result.includes('blah.txt'));
const result = await rig.run({ stdin: prompt });
const foundToolCall = await rig.waitForToolCall('run_shell_command');
// Add debugging information
if (!foundToolCall || !result.includes('test-stdin')) {
printDebugInfo(rig, result, {
'Test type': 'Stdin test',
'Found tool call': foundToolCall,
'Contains test-stdin': result.includes('test-stdin'),
});
}
assert.ok(foundToolCall, 'Expected to find a run_shell_command tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'test-stdin', 'Shell command stdin test');
});

View File

@@ -6,16 +6,36 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to save to memory', async (t) => {
test('should be able to save to memory', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to save to memory');
const prompt = `remember that my favorite color is blue.
what is my favorite color? tell me that and surround it with $ symbol`;
const result = await rig.run(prompt);
assert.ok(result.toLowerCase().includes('$blue$'));
const foundToolCall = await rig.waitForToolCall('save_memory');
// Add debugging information
if (!foundToolCall || !result.toLowerCase().includes('blue')) {
const allTools = printDebugInfo(rig, result, {
'Found tool call': foundToolCall,
'Contains blue': result.toLowerCase().includes('blue'),
});
console.error(
'Memory tool calls:',
allTools
.filter((t) => t.toolRequest.name === 'save_memory')
.map((t) => t.toolRequest.args),
);
}
assert.ok(foundToolCall, 'Expected to find a save_memory tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'blue', 'Save memory test');
});

View File

@@ -4,67 +4,208 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { test, describe, before, after } from 'node:test';
/**
* This test verifies MCP (Model Context Protocol) server integration.
* It uses a minimal MCP server implementation that doesn't require
* external dependencies, making it compatible with Docker sandbox mode.
*/
import { test, describe, before } from 'node:test';
import { strict as assert } from 'node:assert';
import { TestRig } from './test-helper.js';
import { spawn } from 'child_process';
import { TestRig, validateModelOutput } from './test-helper.js';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { writeFileSync, unlinkSync } from 'fs';
import { writeFileSync } from 'fs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const serverScriptPath = join(__dirname, './temp-server.js');
const serverScript = `
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
// Create a minimal MCP server that doesn't require external dependencies
// This implements the MCP protocol directly using Node.js built-ins
const serverScript = `#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const server = new McpServer({
name: 'addition-server',
version: '1.0.0',
const readline = require('readline');
const fs = require('fs');
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
const debugEnabled = process.env.MCP_DEBUG === 'true' || process.env.VERBOSE === 'true';
function debug(msg) {
if (debugEnabled) {
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
}
}
debug('MCP server starting...');
// Simple JSON-RPC implementation for MCP
class SimpleJSONRPC {
constructor() {
this.handlers = new Map();
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
this.rl.on('line', (line) => {
debug(\`Received line: \${line}\`);
try {
const message = JSON.parse(line);
debug(\`Parsed message: \${JSON.stringify(message)}\`);
this.handleMessage(message);
} catch (e) {
debug(\`Parse error: \${e.message}\`);
}
});
}
send(message) {
const msgStr = JSON.stringify(message);
debug(\`Sending message: \${msgStr}\`);
process.stdout.write(msgStr + '\\n');
}
async handleMessage(message) {
if (message.method && this.handlers.has(message.method)) {
try {
const result = await this.handlers.get(message.method)(message.params || {});
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
result
});
}
} catch (error) {
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: error.message
}
});
}
}
} else if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: 'Method not found'
}
});
}
}
on(method, handler) {
this.handlers.set(method, handler);
}
}
// Create MCP server
const rpc = new SimpleJSONRPC();
// Handle initialize
rpc.on('initialize', async (params) => {
debug('Handling initialize request');
return {
protocolVersion: '2024-11-05',
capabilities: {
tools: {}
},
serverInfo: {
name: 'addition-server',
version: '1.0.0'
}
};
});
server.registerTool(
'add',
{
title: 'Addition Tool',
description: 'Add two numbers',
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }],
}),
);
// Handle tools/list
rpc.on('tools/list', async () => {
debug('Handling tools/list request');
return {
tools: [{
name: 'add',
description: 'Add two numbers',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' }
},
required: ['a', 'b']
}
}]
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
// Handle tools/call
rpc.on('tools/call', async (params) => {
debug(\`Handling tools/call request for tool: \${params.name}\`);
if (params.name === 'add') {
const { a, b } = params.arguments;
return {
content: [{
type: 'text',
text: String(a + b)
}]
};
}
throw new Error('Unknown tool: ' + params.name);
});
// Send initialization notification
rpc.send({
jsonrpc: '2.0',
method: 'initialized'
});
`;
describe('simple-mcp-server', () => {
const rig = new TestRig();
let child;
before(() => {
writeFileSync(serverScriptPath, serverScript);
child = spawn('node', [serverScriptPath], {
stdio: ['pipe', 'pipe', 'pipe'],
before(async () => {
// Setup test directory with MCP server configuration
await rig.setup('simple-mcp-server', {
settings: {
mcpServers: {
'addition-server': {
command: 'node',
args: ['mcp-server.cjs'],
},
},
},
});
child.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
// Wait for the server to be ready
return new Promise((resolve) => setTimeout(resolve, 2000));
// Create server script in the test directory
const testServerPath = join(rig.testDir, 'mcp-server.cjs');
writeFileSync(testServerPath, serverScript);
// Make the script executable (though running with 'node' should work anyway)
if (process.platform !== 'win32') {
const { chmodSync } = await import('fs');
chmodSync(testServerPath, 0o755);
}
});
after(() => {
child.kill();
unlinkSync(serverScriptPath);
});
test('should add two numbers', async () => {
// Test directory is already set up in before hook
// Just run the command - MCP server config is in settings.json
const output = await rig.run('add 5 and 10');
test('should add two numbers', () => {
rig.setup('should add two numbers');
const output = rig.run('add 5 and 10');
assert.ok(output.includes('15'));
const foundToolCall = await rig.waitForToolCall('add');
assert.ok(foundToolCall, 'Expected to find an add tool call');
// Validate model output - will throw if no output, fail if missing expected content
validateModelOutput(output, '15', 'MCP server test');
assert.ok(output.includes('15'), 'Expected output to contain the sum (15)');
});
});

View File

@@ -4,11 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'child_process';
import { execSync, spawn } from 'child_process';
import { parse } from 'shell-quote';
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { env } from 'process';
import { fileExists } from '../scripts/telemetry_utils.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -19,17 +21,129 @@ function sanitizeTestName(name) {
.replace(/-+/g, '-');
}
// Helper to create detailed error messages
export function createToolCallErrorMessage(expectedTools, foundTools, result) {
const expectedStr = Array.isArray(expectedTools)
? expectedTools.join(' or ')
: expectedTools;
return (
`Expected to find ${expectedStr} tool call(s). ` +
`Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` +
`Output preview: ${result ? result.substring(0, 200) + '...' : 'no output'}`
);
}
// Helper to print debug information when tests fail
export function printDebugInfo(rig, result, context = {}) {
console.error('Test failed - Debug info:');
console.error('Result length:', result.length);
console.error('Result (first 500 chars):', result.substring(0, 500));
console.error(
'Result (last 500 chars):',
result.substring(result.length - 500),
);
// Print any additional context provided
Object.entries(context).forEach(([key, value]) => {
console.error(`${key}:`, value);
});
// Check what tools were actually called
const allTools = rig.readToolLogs();
console.error(
'All tool calls found:',
allTools.map((t) => t.toolRequest.name),
);
return allTools;
}
// Helper to validate model output and warn about unexpected content
export function validateModelOutput(
result,
expectedContent = null,
testName = '',
) {
// First, check if there's any output at all (this should fail the test if missing)
if (!result || result.trim().length === 0) {
throw new Error('Expected LLM to return some output');
}
// If expectedContent is provided, check for it and warn if missing
if (expectedContent) {
const contents = Array.isArray(expectedContent)
? expectedContent
: [expectedContent];
const missingContent = contents.filter((content) => {
if (typeof content === 'string') {
return !result.toLowerCase().includes(content.toLowerCase());
} else if (content instanceof RegExp) {
return !content.test(result);
}
return false;
});
if (missingContent.length > 0) {
console.warn(
`Warning: LLM did not include expected content in response: ${missingContent.join(', ')}.`,
'This is not ideal but not a test failure.',
);
console.warn(
'The tool was called successfully, which is the main requirement.',
);
return false;
} else if (process.env.VERBOSE === 'true') {
console.log(`${testName}: Model output validated successfully.`);
}
return true;
}
return true;
}
export class TestRig {
constructor() {
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
this.testDir = null;
}
setup(testName) {
// Get timeout based on environment
getDefaultTimeout() {
if (env.CI) return 60000; // 1 minute in CI
if (env.GEMINI_SANDBOX) return 30000; // 30s in containers
return 15000; // 15s locally
}
setup(testName, options = {}) {
this.testName = testName;
const sanitizedName = sanitizeTestName(testName);
this.testDir = join(env.INTEGRATION_TEST_FILE_DIR, sanitizedName);
mkdirSync(this.testDir, { recursive: true });
// Create a settings file to point the CLI to the local collector
const geminiDir = join(this.testDir, '.qwen');
mkdirSync(geminiDir, { recursive: true });
// In sandbox mode, use an absolute path for telemetry inside the container
// The container mounts the test directory at the same path as the host
const telemetryPath =
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
? join(this.testDir, 'telemetry.log') // Absolute path in test directory
: env.TELEMETRY_LOG_FILE; // Absolute path for non-sandbox
const settings = {
telemetry: {
enabled: true,
target: 'local',
otlpEndpoint: '',
outfile: telemetryPath,
},
sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false,
...options.settings, // Allow tests to override/add settings
};
writeFileSync(
join(geminiDir, 'settings.json'),
JSON.stringify(settings, null, 2),
);
}
createFile(fileName, content) {
@@ -39,7 +153,7 @@ export class TestRig {
}
mkdir(dir) {
mkdirSync(join(this.testDir, dir));
mkdirSync(join(this.testDir, dir), { recursive: true });
}
sync() {
@@ -70,19 +184,88 @@ export class TestRig {
command += ` ${args.join(' ')}`;
const output = execSync(command, execOptions);
const commandArgs = parse(command);
const node = commandArgs.shift();
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
const testId = `${env.TEST_FILE_NAME.replace(
'.test.js',
'',
)}:${this.testName.replace(/ /g, '-')}`;
console.log(`--- TEST: ${testId} ---`);
console.log(output);
console.log(`--- END TEST: ${testId} ---`);
const child = spawn(node, commandArgs, {
cwd: this.testDir,
stdio: 'pipe',
});
let stdout = '';
let stderr = '';
// Handle stdin if provided
if (execOptions.input) {
child.stdin.write(execOptions.input);
child.stdin.end();
}
return output;
child.stdout.on('data', (data) => {
stdout += data;
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
process.stdout.write(data);
}
});
child.stderr.on('data', (data) => {
stderr += data;
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
process.stderr.write(data);
}
});
const promise = new Promise((resolve, reject) => {
child.on('close', (code) => {
if (code === 0) {
// Store the raw stdout for Podman telemetry parsing
this._lastRunStdout = stdout;
// Filter out telemetry output when running with Podman
// Podman seems to output telemetry to stdout even when writing to file
let result = stdout;
if (env.GEMINI_SANDBOX === 'podman') {
// Remove telemetry JSON objects from output
// They are multi-line JSON objects that start with { and contain telemetry fields
const lines = result.split('\n');
const filteredLines = [];
let inTelemetryObject = false;
let braceDepth = 0;
for (const line of lines) {
if (!inTelemetryObject && line.trim() === '{') {
// Check if this might be start of telemetry object
inTelemetryObject = true;
braceDepth = 1;
} else if (inTelemetryObject) {
// Count braces to track nesting
for (const char of line) {
if (char === '{') braceDepth++;
else if (char === '}') braceDepth--;
}
// Check if we've closed all braces
if (braceDepth === 0) {
inTelemetryObject = false;
// Skip this line (the closing brace)
continue;
}
} else {
// Not in telemetry object, keep the line
filteredLines.push(line);
}
}
result = filteredLines.join('\n');
}
resolve(result);
} else {
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
}
});
});
return promise;
}
readFile(fileName) {
@@ -98,4 +281,312 @@ export class TestRig {
}
return content;
}
async cleanup() {
// Clean up test directory
if (this.testDir && !env.KEEP_OUTPUT) {
try {
execSync(`rm -rf ${this.testDir}`);
} catch (error) {
// Ignore cleanup errors
if (env.VERBOSE === 'true') {
console.warn('Cleanup warning:', error.message);
}
}
}
}
async waitForTelemetryReady() {
// In sandbox mode, telemetry is written to a relative path in the test directory
const logFilePath =
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
? join(this.testDir, 'telemetry.log')
: env.TELEMETRY_LOG_FILE;
if (!logFilePath) return;
// Wait for telemetry file to exist and have content
await this.poll(
() => {
if (!fileExists(logFilePath)) return false;
try {
const content = readFileSync(logFilePath, 'utf-8');
// Check if file has meaningful content (at least one complete JSON object)
return content.includes('"event.name"');
} catch (_e) {
return false;
}
},
2000, // 2 seconds max - reduced since telemetry should flush on exit now
100, // check every 100ms
);
}
async waitForToolCall(toolName, timeout) {
// Use environment-specific timeout
if (!timeout) {
timeout = this.getDefaultTimeout();
}
// Wait for telemetry to be ready before polling for tool calls
await this.waitForTelemetryReady();
return this.poll(
() => {
const toolLogs = this.readToolLogs();
return toolLogs.some((log) => log.toolRequest.name === toolName);
},
timeout,
100,
);
}
async waitForAnyToolCall(toolNames, timeout) {
// Use environment-specific timeout
if (!timeout) {
timeout = this.getDefaultTimeout();
}
// Wait for telemetry to be ready before polling for tool calls
await this.waitForTelemetryReady();
return this.poll(
() => {
const toolLogs = this.readToolLogs();
return toolNames.some((name) =>
toolLogs.some((log) => log.toolRequest.name === name),
);
},
timeout,
100,
);
}
async poll(predicate, timeout, interval) {
const startTime = Date.now();
let attempts = 0;
while (Date.now() - startTime < timeout) {
attempts++;
const result = predicate();
if (env.VERBOSE === 'true' && attempts % 5 === 0) {
console.log(
`Poll attempt ${attempts}: ${result ? 'success' : 'waiting...'}`,
);
}
if (result) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
if (env.VERBOSE === 'true') {
console.log(`Poll timed out after ${attempts} attempts`);
}
return false;
}
_parseToolLogsFromStdout(stdout) {
const logs = [];
// The console output from Podman is JavaScript object notation, not JSON
// Look for tool call events in the output
// Updated regex to handle tool names with hyphens and underscores
const toolCallPattern =
/body:\s*'Tool call:\s*([\w-]+)\..*?Success:\s*(\w+)\..*?Duration:\s*(\d+)ms\.'/g;
const matches = [...stdout.matchAll(toolCallPattern)];
for (const match of matches) {
const toolName = match[1];
const success = match[2] === 'true';
const duration = parseInt(match[3], 10);
// Try to find function_args nearby
const matchIndex = match.index || 0;
const contextStart = Math.max(0, matchIndex - 500);
const contextEnd = Math.min(stdout.length, matchIndex + 500);
const context = stdout.substring(contextStart, contextEnd);
// Look for function_args in the context
let args = '{}';
const argsMatch = context.match(/function_args:\s*'([^']+)'/);
if (argsMatch) {
args = argsMatch[1];
}
// Also try to find function_name to double-check
// Updated regex to handle tool names with hyphens and underscores
const nameMatch = context.match(/function_name:\s*'([\w-]+)'/);
const actualToolName = nameMatch ? nameMatch[1] : toolName;
logs.push({
timestamp: Date.now(),
toolRequest: {
name: actualToolName,
args: args,
success: success,
duration_ms: duration,
},
});
}
// If no matches found with the simple pattern, try the JSON parsing approach
// in case the format changes
if (logs.length === 0) {
const lines = stdout.split('\n');
let currentObject = '';
let inObject = false;
let braceDepth = 0;
for (const line of lines) {
if (!inObject && line.trim() === '{') {
inObject = true;
braceDepth = 1;
currentObject = line + '\n';
} else if (inObject) {
currentObject += line + '\n';
// Count braces
for (const char of line) {
if (char === '{') braceDepth++;
else if (char === '}') braceDepth--;
}
// If we've closed all braces, try to parse the object
if (braceDepth === 0) {
inObject = false;
try {
const obj = JSON.parse(currentObject);
// Check for tool call in different formats
if (
obj.body &&
obj.body.includes('Tool call:') &&
obj.attributes
) {
const bodyMatch = obj.body.match(/Tool call: (\w+)\./);
if (bodyMatch) {
logs.push({
timestamp: obj.timestamp || Date.now(),
toolRequest: {
name: bodyMatch[1],
args: obj.attributes.function_args || '{}',
success: obj.attributes.success !== false,
duration_ms: obj.attributes.duration_ms || 0,
},
});
}
} else if (
obj.attributes &&
obj.attributes['event.name'] === 'gemini_cli.tool_call'
) {
logs.push({
timestamp: obj.attributes['event.timestamp'],
toolRequest: {
name: obj.attributes.function_name,
args: obj.attributes.function_args,
success: obj.attributes.success,
duration_ms: obj.attributes.duration_ms,
},
});
}
} catch (_e) {
// Not valid JSON
}
currentObject = '';
}
}
}
}
return logs;
}
readToolLogs() {
// For Podman, first check if telemetry file exists and has content
// If not, fall back to parsing from stdout
if (env.GEMINI_SANDBOX === 'podman') {
// Try reading from file first
const logFilePath = join(this.testDir, 'telemetry.log');
if (fileExists(logFilePath)) {
try {
const content = readFileSync(logFilePath, 'utf-8');
if (content && content.includes('"event.name"')) {
// File has content, use normal file parsing
// Continue to the normal file parsing logic below
} else if (this._lastRunStdout) {
// File exists but is empty or doesn't have events, parse from stdout
return this._parseToolLogsFromStdout(this._lastRunStdout);
}
} catch (_e) {
// Error reading file, fall back to stdout
if (this._lastRunStdout) {
return this._parseToolLogsFromStdout(this._lastRunStdout);
}
}
} else if (this._lastRunStdout) {
// No file exists, parse from stdout
return this._parseToolLogsFromStdout(this._lastRunStdout);
}
}
// In sandbox mode, telemetry is written to a relative path in the test directory
const logFilePath =
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
? join(this.testDir, 'telemetry.log')
: env.TELEMETRY_LOG_FILE;
if (!logFilePath) {
console.warn(`TELEMETRY_LOG_FILE environment variable not set`);
return [];
}
// Check if file exists, if not return empty array (file might not be created yet)
if (!fileExists(logFilePath)) {
return [];
}
const content = readFileSync(logFilePath, 'utf-8');
// Split the content into individual JSON objects
// They are separated by "}\n{" pattern
const jsonObjects = content
.split(/}\s*\n\s*{/)
.map((obj, index, array) => {
// Add back the braces we removed during split
if (index > 0) obj = '{' + obj;
if (index < array.length - 1) obj = obj + '}';
return obj.trim();
})
.filter((obj) => obj);
const logs = [];
for (const jsonStr of jsonObjects) {
try {
const logData = JSON.parse(jsonStr);
// Look for tool call logs
if (
logData.attributes &&
logData.attributes['event.name'] === 'qwen-code.tool_call'
) {
const toolName = logData.attributes.function_name;
logs.push({
toolRequest: {
name: toolName,
args: logData.attributes.function_args,
success: logData.attributes.success,
duration_ms: logData.attributes.duration_ms,
},
});
}
} catch (_e) {
// Skip objects that aren't valid JSON
if (env.VERBOSE === 'true') {
console.error('Failed to parse telemetry object:', _e.message);
}
}
}
return logs;
}
}

View File

@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to search the web', async () => {
// Skip if Tavily key is not configured
if (!process.env.TAVILY_API_KEY) {
console.warn('Skipping web search test: TAVILY_API_KEY not set');
return;
}
const rig = new TestRig();
await rig.setup('should be able to search the web');
let result;
try {
result = await rig.run(`what is the weather in London`);
} catch (error) {
// Network errors can occur in CI environments
if (
error.message.includes('network') ||
error.message.includes('timeout')
) {
console.warn('Skipping test due to network error:', error.message);
return; // Skip the test
}
throw error; // Re-throw if not a network error
}
const foundToolCall = await rig.waitForToolCall('web_search');
// Add debugging information
if (!foundToolCall) {
const allTools = printDebugInfo(rig, result);
// Check if the tool call failed due to network issues
const failedSearchCalls = allTools.filter(
(t) => t.toolRequest.name === 'web_search' && !t.toolRequest.success,
);
if (failedSearchCalls.length > 0) {
console.warn(
'web_search tool was called but failed, possibly due to network issues',
);
console.warn(
'Failed calls:',
failedSearchCalls.map((t) => t.toolRequest.args),
);
return; // Skip the test if network issues
}
}
assert.ok(foundToolCall, 'Expected to find a call to web_search');
// Validate model output - will throw if no output, warn if missing expected content
const hasExpectedContent = validateModelOutput(
result,
['weather', 'london'],
'Web search test',
);
// If content was missing, log the search queries used
if (!hasExpectedContent) {
const searchCalls = rig
.readToolLogs()
.filter((t) => t.toolRequest.name === 'web_search');
if (searchCalls.length > 0) {
console.warn(
'Search queries used:',
searchCalls.map((t) => t.toolRequest.args),
);
}
}
});

View File

@@ -6,16 +6,63 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import {
TestRig,
createToolCallErrorMessage,
printDebugInfo,
validateModelOutput,
} from './test-helper.js';
test('should be able to write a file', async (t) => {
test('should be able to write a file', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to write a file');
const prompt = `show me an example of using the write tool. put a dad joke in dad.txt`;
await rig.run(prompt);
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('write_file');
// Add debugging information
if (!foundToolCall) {
printDebugInfo(rig, result);
}
const allTools = rig.readToolLogs();
assert.ok(
foundToolCall,
createToolCallErrorMessage(
'write_file',
allTools.map((t) => t.toolRequest.name),
result,
),
);
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'dad.txt', 'Write file test');
const newFilePath = 'dad.txt';
const newFileContent = rig.readFile(newFilePath);
assert.notEqual(newFileContent, '');
// Add debugging for file content
if (newFileContent === '') {
console.error('File was created but is empty');
console.error(
'Tool calls:',
rig.readToolLogs().map((t) => ({
name: t.toolRequest.name,
args: t.toolRequest.args,
})),
);
}
assert.notEqual(newFileContent, '', 'Expected file to have content');
// Log success info if verbose
if (process.env.VERBOSE === 'true') {
console.log(
'File created successfully with content:',
newFileContent.substring(0, 100) + '...',
);
}
});

2748
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.3",
"version": "0.0.8-nightly.3",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.3"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.8-nightly.3"
},
"scripts": {
"start": "node scripts/start.js",
@@ -57,10 +57,12 @@
"LICENSE"
],
"devDependencies": {
"@types/marked": "^5.0.2",
"@types/micromatch": "^4.0.9",
"@types/mime-types": "^3.0.1",
"@types/minimatch": "^5.1.2",
"@types/mock-fs": "^4.13.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/shell-quote": "^1.7.5",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.1.1",
@@ -84,8 +86,5 @@
"typescript-eslint": "^8.30.1",
"vitest": "^3.2.4",
"yargs": "^17.7.2"
},
"dependencies": {
"tiktoken": "^1.0.21"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.3",
"version": "0.0.8-nightly.3",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,12 +25,12 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.3"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.8-nightly.3"
},
"dependencies": {
"@qwen-code/qwen-code-core": "file:../core",
"@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5",
"@qwen-code/qwen-code-core": "file:../core",
"@types/update-notifier": "^6.0.8",
"command-exists": "^1.2.9",
"diff": "^7.0.0",
@@ -46,12 +46,14 @@
"lowlight": "^3.3.0",
"mime-types": "^3.0.1",
"open": "^10.1.2",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tiktoken": "^1.0.21",
"update-notifier": "^7.3.1",
"yargs": "^17.7.2",
"zod": "^3.23.8"
@@ -73,7 +75,8 @@
"pretty-format": "^30.0.2",
"react-dom": "^19.1.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
"vitest": "^3.1.1",
"@qwen-code/qwen-code-test-utils": "file:../test-utils"
},
"engines": {
"node": ">=20"

View File

@@ -45,6 +45,12 @@ export const validateAuthMethod = (authMethod: string): string | null => {
return null;
}
if (authMethod === AuthType.QWEN_OAUTH) {
// Qwen OAuth doesn't require any environment variables for basic setup
// The OAuth flow will handle authentication
return null;
}
return 'Invalid auth method selected.';
};

View File

@@ -37,7 +37,7 @@ describe('Configuration Integration Tests', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'gemini-cli-test-'));
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'qwen-code-test-'));
originalEnv = { ...process.env };
process.env.GEMINI_API_KEY = 'test-api-key';
vi.clearAllMocks();

View File

@@ -6,7 +6,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'os';
import { loadCliConfig, parseArguments, CliArgs } from './config.js';
import * as fs from 'fs';
import * as path from 'path';
import { loadCliConfig, parseArguments } from './config.js';
import { Settings } from './settings.js';
import { Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
@@ -35,9 +37,16 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
);
return {
...actualServer,
IdeClient: {
getInstance: vi.fn().mockReturnValue({
getConnectionStatus: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
}),
},
loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn(
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
(cwd, dirs, debug, fileService, extensionPaths, _maxDirs) =>
Promise.resolve({
memoryContent: extensionPaths?.join(',') || '',
fileCount: extensionPaths?.length || 0,
@@ -492,6 +501,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
await loadCliConfig(settings, extensions, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[],
false,
expect.any(Object),
[
@@ -499,6 +509,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
'/path/to/ext3/context1.md',
'/path/to/ext3/context2.md',
],
'tree',
{
respectGitIgnore: false,
respectGeminiIgnore: true,
@@ -983,7 +994,69 @@ describe('loadCliConfig extensions', () => {
});
});
describe('loadCliConfig ideMode', () => {
describe('loadCliConfig model selection', () => {
it('selects a model from settings.json if provided', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig(
{
model: 'qwen3-coder-plus',
},
[],
'test-session',
argv,
);
expect(config.getModel()).toBe('qwen3-coder-plus');
});
it('uses the default gemini model if nothing is set', async () => {
process.argv = ['node', 'script.js']; // No model set.
const argv = await parseArguments();
const config = await loadCliConfig(
{
// No model set.
},
[],
'test-session',
argv,
);
expect(config.getModel()).toBe('qwen3-coder-plus');
});
it('always prefers model from argvs', async () => {
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
const argv = await parseArguments();
const config = await loadCliConfig(
{
model: 'qwen3-coder-plus',
},
[],
'test-session',
argv,
);
expect(config.getModel()).toBe('qwen3-coder-plus');
});
it('selects the model from argvs if provided', async () => {
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
const argv = await parseArguments();
const config = await loadCliConfig(
{
// No model provided via settings.
},
[],
'test-session',
argv,
);
expect(config.getModel()).toBe('qwen3-coder-plus');
});
});
describe('loadCliConfig ideModeFeature', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
@@ -991,8 +1064,6 @@ describe('loadCliConfig ideMode', () => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
// Explicitly delete TERM_PROGRAM and SANDBOX before each test
delete process.env.TERM_PROGRAM;
delete process.env.SANDBOX;
delete process.env.GEMINI_CLI_IDE_SERVER_PORT;
});
@@ -1008,81 +1079,88 @@ describe('loadCliConfig ideMode', () => {
const settings: Settings = {};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should be false if --ide-mode is true but TERM_PROGRAM is not vscode', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const settings: Settings = {};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should be false if settings.ideMode is true but TERM_PROGRAM is not vscode', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { ideMode: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should be true when --ide-mode is set and TERM_PROGRAM is vscode', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
});
it('should be true when settings.ideMode is true and TERM_PROGRAM is vscode', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = { ideMode: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
});
it('should prioritize --ide-mode (true) over settings (false) when TERM_PROGRAM is vscode', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = { ideMode: false };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
});
it('should prioritize --no-ide-mode (false) over settings (true) even when TERM_PROGRAM is vscode', async () => {
process.argv = ['node', 'script.js', '--no-ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
const settings: Settings = { ideMode: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should be false when --ide-mode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.SANDBOX = 'true';
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should be false when settings.ideMode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.SANDBOX = 'true';
const settings: Settings = { ideMode: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
expect(config.getIdeModeFeature()).toBe(false);
});
});
vi.mock('fs', async () => {
const actualFs = await vi.importActual<typeof fs>('fs');
const MOCK_CWD1 = process.cwd();
const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project');
const mockPaths = new Set([
MOCK_CWD1,
MOCK_CWD2,
path.resolve(path.sep, 'cli', 'path1'),
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(MOCK_CWD2, 'cli', 'path2'),
path.join(MOCK_CWD2, 'settings', 'path3'),
]);
return {
...actualFs,
existsSync: vi.fn((p) => mockPaths.has(p.toString())),
statSync: vi.fn((p) => {
if (mockPaths.has(p.toString())) {
return { isDirectory: () => true };
}
// Fallback for other paths if needed, though the test should be specific.
return actualFs.statSync(p);
}),
realpathSync: vi.fn((p) => p),
};
});
describe('loadCliConfig with includeDirectories', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve(path.sep, 'home', 'user', 'project'),
);
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should combine and resolve paths from settings and CLI arguments', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
process.argv = [
'node',
'script.js',
'--include-directories',
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
];
const argv = await parseArguments();
const settings: Settings = {
includeDirectories: [
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
],
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
const expected = [
mockCwd,
path.resolve(path.sep, 'cli', 'path1'),
path.join(mockCwd, 'cli', 'path2'),
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
];
expect(config.getWorkspaceContext().getDirectories()).toEqual(
expect.arrayContaining(expected),
);
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
expected.length,
);
});
});

View File

@@ -4,6 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import { homedir } from 'node:os';
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
@@ -19,13 +22,13 @@ import {
FileDiscoveryService,
TelemetryTarget,
FileFilteringOptions,
IdeClient,
} from '@qwen-code/qwen-code-core';
import { Settings } from './settings.js';
import { Extension, annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
@@ -59,11 +62,14 @@ export interface CliArgs {
experimentalAcp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
ideMode: boolean | undefined;
ideModeFeature: boolean | undefined;
openaiLogging: boolean | undefined;
openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
loadMemoryFromIncludeDirectories: boolean | undefined;
tavilyApiKey: string | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
@@ -77,7 +83,7 @@ export async function parseArguments(): Promise<CliArgs> {
alias: 'm',
type: 'string',
description: `Model`,
default: process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL,
default: process.env.GEMINI_MODEL,
})
.option('prompt', {
alias: 'p',
@@ -193,7 +199,7 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('ide-mode', {
.option('ide-mode-feature', {
type: 'boolean',
description: 'Run in IDE mode?',
})
@@ -210,11 +216,30 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search functionality',
})
.option('proxy', {
type: 'string',
description:
'Proxy for gemini client, like schema://user:password@host:port',
})
.option('include-directories', {
type: 'array',
string: true,
description:
'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
coerce: (dirs: string[]) =>
// Handle comma-separated values
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
})
.option('load-memory-from-include-directories', {
type: 'boolean',
description:
'If true, when refreshing memory, QWEN.md files should be loaded from all directories that are added. If false, QWEN.md files should only be loaded from the primary working directory.',
default: false,
})
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
@@ -230,7 +255,11 @@ export async function parseArguments(): Promise<CliArgs> {
});
yargsInstance.wrap(yargsInstance.terminalWidth());
return yargsInstance.argv;
const result = yargsInstance.parseSync();
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
return result as CliArgs;
}
// This function is now a thin wrapper around the server's implementation.
@@ -238,25 +267,37 @@ export async function parseArguments(): Promise<CliArgs> {
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
export async function loadHierarchicalGeminiMemory(
currentWorkingDirectory: string,
includeDirectoriesToReadGemini: readonly string[] = [],
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
extensionContextFilePaths: string[] = [],
memoryImportFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
): Promise<{ memoryContent: string; fileCount: number }> {
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
const realHome = fs.realpathSync(path.resolve(homedir()));
const isHomeDirectory = realCwd === realHome;
// If it is the home directory, pass an empty string to the core memory
// function to signal that it should skip the workspace search.
const effectiveCwd = isHomeDirectory ? '' : currentWorkingDirectory;
if (debugMode) {
logger.debug(
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory}`,
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory} (memoryImportFormat: ${memoryImportFormat})`,
);
}
// Directly call the server function.
// The server function will use its own homedir() for the global path.
// Directly call the server function with the corrected path.
return loadServerHierarchicalMemory(
currentWorkingDirectory,
effectiveCwd,
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensionContextFilePaths,
memoryImportFormat,
fileFilteringOptions,
settings.memoryDiscoveryMaxDirs,
);
@@ -272,17 +313,13 @@ export async function loadCliConfig(
argv.debug ||
[process.env.DEBUG, process.env.DEBUG_MODE].some(
(v) => v === 'true' || v === '1',
);
) ||
false;
const memoryImportFormat = settings.memoryImportFormat || 'tree';
const ideMode =
(argv.ideMode ?? settings.ideMode ?? false) &&
process.env.TERM_PROGRAM === 'vscode' &&
!process.env.SANDBOX;
let ideClient: IdeClient | undefined;
if (ideMode) {
ideClient = new IdeClient();
}
const ideMode = settings.ideMode ?? false;
const ideModeFeature =
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
const allExtensions = annotateActiveExtensions(
extensions,
@@ -302,6 +339,11 @@ export async function loadCliConfig(
process.env.OPENAI_BASE_URL = argv.openaiBaseUrl;
}
// Handle Tavily API key from command line
if (argv.tavilyApiKey) {
process.env.TAVILY_API_KEY = argv.tavilyApiKey;
}
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@@ -324,13 +366,19 @@ export async function loadCliConfig(
...settings.fileFiltering,
};
const includeDirectories = (settings.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.loadMemoryFromIncludeDirectories ? includeDirectories : [],
debugMode,
fileService,
settings,
extensionContextFilePaths,
memoryImportFormat,
fileFiltering,
);
@@ -385,12 +433,18 @@ export async function loadCliConfig(
}
const sandboxConfig = await loadSandboxConfig(settings, argv);
const cliVersion = await getCliVersion();
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: process.cwd(),
includeDirectories,
loadMemoryFromIncludeDirectories:
argv.loadMemoryFromIncludeDirectories ||
settings.loadMemoryFromIncludeDirectories ||
false,
debugMode,
question: argv.promptInteractive || argv.prompt || '',
fullContext: argv.allFiles || argv.all_files || false,
@@ -438,10 +492,10 @@ export async function loadCliConfig(
cwd: process.cwd(),
fileDiscoveryService: fileService,
bugCommand: settings.bugCommand,
model: argv.model!,
model: argv.model || settings.model || DEFAULT_GEMINI_MODEL,
extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1,
sessionTokenLimit: settings.sessionTokenLimit ?? 32000,
sessionTokenLimit: settings.sessionTokenLimit ?? -1,
maxFolderItems: settings.maxFolderItems ?? 20,
experimentalAcp: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
@@ -450,7 +504,7 @@ export async function loadCliConfig(
noBrowser: !!process.env.NO_BROWSER,
summarizeToolOutput: settings.summarizeToolOutput,
ideMode,
ideClient,
ideModeFeature,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.enableOpenAILogging
@@ -468,6 +522,9 @@ export async function loadCliConfig(
},
],
contentGenerator: settings.contentGenerator,
cliVersion,
tavilyApiKey:
argv.tavilyApiKey || settings.tavilyApiKey || process.env.TAVILY_API_KEY,
});
}

View File

@@ -29,10 +29,10 @@ describe('loadExtensions', () => {
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
path.join(os.tmpdir(), 'qwen-code-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
path.join(os.tmpdir(), 'qwen-code-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
});
@@ -42,6 +42,81 @@ describe('loadExtensions', () => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should include extension path in loaded extension', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
const config = {
name: 'test-extension',
version: '1.0.0',
};
fs.writeFileSync(
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(config),
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension');
});
it('should include extension path in loaded extension', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
const config = {
name: 'test-extension',
version: '1.0.0',
};
fs.writeFileSync(
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(config),
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension');
});
it('should include extension path in loaded extension', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
const config = {
name: 'test-extension',
version: '1.0.0',
};
fs.writeFileSync(
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(config),
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension');
});
it('should load context file path when QWEN.md is present', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,

View File

@@ -13,6 +13,7 @@ export const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export interface Extension {
path: string;
config: ExtensionConfig;
contextFiles: string[];
}
@@ -90,6 +91,7 @@ function loadExtension(extensionDir: string): Extension | null {
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
path: extensionDir,
config,
contextFiles,
};
@@ -121,6 +123,7 @@ export function annotateActiveExtensions(
name: extension.config.name,
version: extension.config.version,
isActive: true,
path: extension.path,
}));
}
@@ -136,6 +139,7 @@ export function annotateActiveExtensions(
name: extension.config.name,
version: extension.config.version,
isActive: false,
path: extension.path,
}));
}
@@ -153,6 +157,7 @@ export function annotateActiveExtensions(
name: extension.config.name,
version: extension.config.version,
isActive,
path: extension.path,
});
}

View File

@@ -59,7 +59,21 @@ const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join(
'settings.json',
);
vi.mock('fs');
vi.mock('fs', async (importOriginal) => {
// Get all the functions from the real 'fs' module
const actualFs = await importOriginal<typeof fs>();
return {
...actualFs, // Keep all the real functions
// Now, just override the ones we need for the test
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: (p: string) => p,
};
});
vi.mock('strip-json-comments', () => ({
default: vi.fn((content) => content),
}));
@@ -98,6 +112,7 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
expect(settings.errors.length).toBe(0);
});
@@ -131,6 +146,7 @@ describe('Settings Loading and Merging', () => {
...systemSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -164,6 +180,7 @@ describe('Settings Loading and Merging', () => {
...userSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -195,6 +212,7 @@ describe('Settings Loading and Merging', () => {
...workspaceSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -232,6 +250,7 @@ describe('Settings Loading and Merging', () => {
contextFileName: 'WORKSPACE_CONTEXT.md',
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -281,6 +300,7 @@ describe('Settings Loading and Merging', () => {
allowMCPServers: ['server1', 'server2'],
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -320,6 +340,86 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.contextFileName).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 = {
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'],
};
(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.excludedProjectEnvVars).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 = {
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
};
(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.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
});
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'],
};
const workspaceSettingsContent = {
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
};
(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 '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
'DEBUG',
'NODE_ENV',
'USER_VAR',
]);
expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
expect(settings.merged.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
});
it('should default contextFileName to undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = { theme: 'dark' };
@@ -522,6 +622,40 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.mcpServers).toEqual({});
});
it('should merge includeDirectories from all scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const systemSettingsContent = {
includeDirectories: ['/system/dir'],
};
const userSettingsContent = {
includeDirectories: ['/user/dir1', '/user/dir2'],
};
const workspaceSettingsContent = {
includeDirectories: ['/workspace/dir'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.includeDirectories).toEqual([
'/system/dir',
'/user/dir1',
'/user/dir2',
'/workspace/dir',
]);
});
it('should handle JSON parsing errors gracefully', () => {
(mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist"
const invalidJsonContent = 'invalid json';
@@ -560,6 +694,7 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
// Check that error objects are populated in settings.errors
@@ -777,6 +912,48 @@ describe('Settings Loading and Merging', () => {
}
});
it('should correctly merge dnsResolutionOrder with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
dnsResolutionOrder: 'ipv4first',
};
const workspaceSettingsContent = {
dnsResolutionOrder: 'verbatim',
};
(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 '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.dnsResolutionOrder).toBe('verbatim');
});
it('should use user dnsResolutionOrder if workspace is not defined', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
dnsResolutionOrder: 'verbatim',
};
(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.dnsResolutionOrder).toBe('verbatim');
});
it('should leave unresolved environment variables as is', () => {
const userSettingsContent = { apiKey: '$UNDEFINED_VAR' };
(mockFsExistsSync as Mock).mockImplementation(
@@ -954,6 +1131,7 @@ describe('Settings Loading and Merging', () => {
...systemSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
});
@@ -999,4 +1177,140 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.theme).toBe('ocean');
});
});
describe('excludedProjectEnvVars integration', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => {
// Create a workspace settings file with excludedProjectEnvVars
const workspaceSettingsContent = {
excludedProjectEnvVars: ['DEBUG', 'DEBUG_MODE'],
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
// Mock findEnvFile to return a project .env file
const originalFindEnvFile = (
loadSettings as unknown as { findEnvFile: () => string }
).findEnvFile;
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
() => '/mock/project/.env';
// Mock fs.readFileSync for .env file content
const originalReadFileSync = fs.readFileSync;
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === '/mock/project/.env') {
return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key';
}
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
return JSON.stringify(workspaceSettingsContent);
}
return '{}';
},
);
try {
// This will call loadEnvironment internally with the merged settings
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify the settings were loaded correctly
expect(settings.merged.excludedProjectEnvVars).toEqual([
'DEBUG',
'DEBUG_MODE',
]);
// Note: We can't directly test process.env changes here because the mocking
// prevents the actual file system operations, but we can verify the settings
// are correctly merged and passed to loadEnvironment
} finally {
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
originalFindEnvFile;
(fs.readFileSync as Mock).mockImplementation(originalReadFileSync);
}
});
it('should respect custom excludedProjectEnvVars from user settings', () => {
const userSettingsContent = {
excludedProjectEnvVars: ['NODE_ENV', 'DEBUG'],
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
(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.user.settings.excludedProjectEnvVars).toEqual([
'NODE_ENV',
'DEBUG',
]);
expect(settings.merged.excludedProjectEnvVars).toEqual([
'NODE_ENV',
'DEBUG',
]);
});
it('should merge excludedProjectEnvVars with workspace taking precedence', () => {
const userSettingsContent = {
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'],
};
const workspaceSettingsContent = {
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
};
(mockFsExistsSync as Mock).mockReturnValue(true);
(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 '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
'DEBUG',
'NODE_ENV',
'USER_VAR',
]);
expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
expect(settings.merged.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
});
});
});

View File

@@ -24,6 +24,7 @@ import { CustomTheme } from '../ui/themes/theme.js';
export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
export function getSystemSettingsPath(): string {
if (process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) {
@@ -38,6 +39,12 @@ export function getSystemSettingsPath(): string {
}
}
export function getWorkspaceSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
}
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
export enum SettingScope {
User = 'User',
Workspace = 'Workspace',
@@ -60,6 +67,7 @@ export interface Settings {
theme?: string;
customThemes?: Record<string, CustomTheme>;
selectedAuthType?: AuthType;
useExternalAuth?: boolean;
sandbox?: boolean | string;
coreTools?: string[];
excludeTools?: string[];
@@ -78,6 +86,8 @@ export interface Settings {
bugCommand?: BugCommandSettings;
checkpointing?: CheckpointingSettings;
autoConfigureMaxOldSpaceSize?: boolean;
/** The model name to use (e.g 'gemini-9.0-pro') */
model?: string;
enableOpenAILogging?: boolean;
// Git-aware file filtering settings
@@ -105,10 +115,24 @@ export interface Settings {
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
vimMode?: boolean;
memoryImportFormat?: 'tree' | 'flat';
// Add other settings here.
// Flag to be removed post-launch.
ideModeFeature?: boolean;
/// IDE mode setting configured via slash command toggle.
ideMode?: boolean;
// Setting for disabling auto-update.
disableAutoUpdate?: boolean;
// Setting for disabling the update nag message.
disableUpdateNag?: boolean;
memoryDiscoveryMaxDirs?: number;
// Environment variables to exclude from project .env files
excludedProjectEnvVars?: string[];
dnsResolutionOrder?: DnsResolutionOrder;
sampling_params?: Record<string, unknown>;
systemPromptMappings?: Array<{
baseUrls: string[];
@@ -119,6 +143,13 @@ export interface Settings {
timeout?: number;
maxRetries?: number;
};
includeDirectories?: string[];
loadMemoryFromIncludeDirectories?: boolean;
// Web search API keys
tavilyApiKey?: string;
}
export interface SettingsError {
@@ -174,6 +205,11 @@ export class LoadedSettings {
...(workspace.mcpServers || {}),
...(system.mcpServers || {}),
},
includeDirectories: [
...(system.includeDirectories || []),
...(user.includeDirectories || []),
...(workspace.includeDirectories || []),
],
};
}
@@ -295,15 +331,61 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void {
}
}
export function loadEnvironment(): void {
export function loadEnvironment(settings?: Settings): void {
const envFilePath = findEnvFile(process.cwd());
// Cloud Shell environment variable handling
if (process.env.CLOUD_SHELL === 'true') {
setUpCloudShellEnvironment(envFilePath);
}
// If no settings provided, try to load workspace settings for exclusions
let resolvedSettings = settings;
if (!resolvedSettings) {
const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd());
try {
if (fs.existsSync(workspaceSettingsPath)) {
const workspaceContent = fs.readFileSync(
workspaceSettingsPath,
'utf-8',
);
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(workspaceContent),
) as Settings;
resolvedSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
}
} catch (_e) {
// Ignore errors loading workspace settings
}
}
if (envFilePath) {
dotenv.config({ path: envFilePath, quiet: true });
// Manually parse and load environment variables to handle exclusions correctly.
// This avoids modifying environment variables that were already set from the shell.
try {
const envFileContent = fs.readFileSync(envFilePath, 'utf-8');
const parsedEnv = dotenv.parse(envFileContent);
const excludedVars =
resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS;
const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR);
for (const key in parsedEnv) {
if (Object.hasOwn(parsedEnv, key)) {
// If it's a project .env file, skip loading excluded variables.
if (isProjectEnvFile && excludedVars.includes(key)) {
continue;
}
// Load variable only if it's not already set in the environment.
if (!Object.hasOwn(process.env, key)) {
process.env[key] = parsedEnv[key];
}
}
}
} catch (_e) {
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
}
}
}
@@ -312,12 +394,29 @@ export function loadEnvironment(): void {
* Project settings override user settings.
*/
export function loadSettings(workspaceDir: string): LoadedSettings {
loadEnvironment();
let systemSettings: Settings = {};
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
const resolvedHomeDir = path.resolve(homedir());
let realWorkspaceDir = resolvedWorkspaceDir;
try {
// fs.realpathSync gets the "true" path, resolving any symlinks
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
} catch (_e) {
// This is okay. The path might not exist yet, and that's a valid state.
}
// We expect homedir to always exist and be resolvable.
const realHomeDir = fs.realpathSync(resolvedHomeDir);
const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir);
// Load system settings
try {
if (fs.existsSync(systemSettingsPath)) {
@@ -356,37 +455,34 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
});
}
const workspaceSettingsPath = path.join(
workspaceDir,
SETTINGS_DIRECTORY_NAME,
'settings.json',
);
// Load workspace settings
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
workspaceSettings.theme = DefaultLight.name;
} else if (
workspaceSettings.theme &&
workspaceSettings.theme === 'VS2015'
) {
workspaceSettings.theme = DefaultDark.name;
if (realWorkspaceDir !== realHomeDir) {
// Load workspace settings
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
workspaceSettings.theme = DefaultLight.name;
} else if (
workspaceSettings.theme &&
workspaceSettings.theme === 'VS2015'
) {
workspaceSettings.theme = DefaultDark.name;
}
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
return new LoadedSettings(
// Create LoadedSettings first
const loadedSettings = new LoadedSettings(
{
path: systemSettingsPath,
settings: systemSettings,
@@ -401,6 +497,11 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
},
settingsErrors,
);
// Load environment with merged settings
loadEnvironment(loadedSettings.merged);
return loadedSettings;
}
export function saveSettings(settingsFile: SettingsFile): void {

View File

@@ -6,7 +6,11 @@
import stripAnsi from 'strip-ansi';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { main, setupUnhandledRejectionHandler } from './gemini.js';
import {
main,
setupUnhandledRejectionHandler,
validateDnsResolutionOrder,
} from './gemini.js';
import {
LoadedSettings,
SettingsFile,
@@ -211,3 +215,38 @@ describe('gemini.tsx main function', () => {
processExitSpy.mockRestore();
});
});
describe('validateDnsResolutionOrder', () => {
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
consoleWarnSpy.mockRestore();
});
it('should return "ipv4first" when the input is "ipv4first"', () => {
expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first');
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return "verbatim" when the input is "verbatim"', () => {
expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim');
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return the default "ipv4first" when the input is undefined', () => {
expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first');
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return the default "ipv4first" and log a warning for an invalid string', () => {
expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first');
expect(consoleWarnSpy).toHaveBeenCalledOnce();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".',
);
});
});

View File

@@ -12,9 +12,11 @@ import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
import { spawn } from 'node:child_process';
import { start_sandbox } from './utils/sandbox.js';
import {
DnsResolutionOrder,
LoadedSettings,
loadSettings,
SettingScope,
@@ -40,8 +42,27 @@ import {
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js';
export function validateDnsResolutionOrder(
order: string | undefined,
): DnsResolutionOrder {
const defaultValue: DnsResolutionOrder = 'ipv4first';
if (order === undefined) {
return defaultValue;
}
if (order === 'ipv4first' || order === 'verbatim') {
return order;
}
// We don't want to throw here, just warn and use the default.
console.warn(
`Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`,
);
return defaultValue;
}
function getNodeMemoryArgs(config: Config): string[] {
const totalMemoryMB = os.totalmem() / (1024 * 1024);
const heapStats = v8.getHeapStatistics();
@@ -136,6 +157,10 @@ export async function main() {
argv,
);
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.dnsResolutionOrder),
);
if (argv.promptInteractive && !process.stdin.isTTY) {
console.error(
'Error: The --prompt-interactive flag is not supported when piping input from stdin.',
@@ -184,7 +209,10 @@ export async function main() {
: [];
const sandboxConfig = config.getSandbox();
if (sandboxConfig) {
if (settings.merged.selectedAuthType) {
if (
settings.merged.selectedAuthType &&
!settings.merged.useExternalAuth
) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try {
const err = validateAuthMethod(settings.merged.selectedAuthType);
@@ -197,7 +225,7 @@ export async function main() {
process.exit(1);
}
}
await start_sandbox(sandboxConfig, memoryArgs);
await start_sandbox(sandboxConfig, memoryArgs, config);
process.exit(0);
} else {
// Not in a sandbox and not entering one, so relaunch with additional
@@ -246,6 +274,17 @@ export async function main() {
{ exitOnCtrlC: false },
);
checkForUpdates()
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
console.error('Update check failed:', err);
}
});
registerCleanup(() => instance.unmount());
return;
}
@@ -331,6 +370,7 @@ async function loadNonInteractiveConfig(
return await validateNonInteractiveAuth(
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
finalConfig,
);
}

View File

@@ -4,196 +4,170 @@
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
Config,
executeToolCall,
ToolRegistry,
ToolErrorType,
shutdownTelemetry,
GeminiEventType,
ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
import { Config, GeminiClient, ToolRegistry } from '@qwen-code/qwen-code-core';
import { GenerateContentResponse, Part, FunctionCall } from '@google/genai';
import { vi } from 'vitest';
// Mock dependencies
vi.mock('@qwen-code/qwen-code-core', async () => {
const actualCore = await vi.importActual<
typeof import('@qwen-code/qwen-code-core')
>('@qwen-code/qwen-code-core');
// Mock core modules
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actualCore,
GeminiClient: vi.fn(),
ToolRegistry: vi.fn(),
...original,
executeToolCall: vi.fn(),
shutdownTelemetry: vi.fn(),
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
};
});
describe('runNonInteractive', () => {
let mockConfig: Config;
let mockGeminiClient: GeminiClient;
let mockToolRegistry: ToolRegistry;
let mockChat: {
sendMessageStream: ReturnType<typeof vi.fn>;
let mockCoreExecuteToolCall: vi.Mock;
let mockShutdownTelemetry: vi.Mock;
let consoleErrorSpy: vi.SpyInstance;
let processExitSpy: vi.SpyInstance;
let processStdoutSpy: vi.SpyInstance;
let mockGeminiClient: {
sendMessageStream: vi.Mock;
};
let mockProcessStdoutWrite: ReturnType<typeof vi.fn>;
let mockProcessExit: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.resetAllMocks();
mockChat = {
sendMessageStream: vi.fn(),
};
mockGeminiClient = {
getChat: vi.fn().mockResolvedValue(mockChat),
} as unknown as GeminiClient;
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (code?: number) => never);
processStdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
mockToolRegistry = {
getFunctionDeclarations: vi.fn().mockReturnValue([]),
getTool: vi.fn(),
getFunctionDeclarations: vi.fn().mockReturnValue([]),
} as unknown as ToolRegistry;
vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClient);
vi.mocked(ToolRegistry).mockImplementation(() => mockToolRegistry);
mockGeminiClient = {
sendMessageStream: vi.fn(),
};
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
initialize: vi.fn().mockResolvedValue(undefined),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getToolRegistry: vi.fn().mockResolvedValue(mockToolRegistry),
getMaxSessionTurns: vi.fn().mockReturnValue(10),
initialize: vi.fn(),
getIdeMode: vi.fn().mockReturnValue(false),
getFullContext: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockProcessStdoutWrite = vi.fn().mockImplementation(() => true);
process.stdout.write = mockProcessStdoutWrite as any; // Use any to bypass strict signature matching for mock
mockProcessExit = vi
.fn()
.mockImplementation((_code?: number) => undefined as never);
process.exit = mockProcessExit as any; // Use any for process.exit mock
});
afterEach(() => {
vi.restoreAllMocks();
// Restore original process methods if they were globally patched
// This might require storing the original methods before patching them in beforeEach
});
async function* createStreamFromEvents(
events: ServerGeminiStreamEvent[],
): AsyncGenerator<ServerGeminiStreamEvent> {
for (const event of events) {
yield event;
}
}
it('should process input and write text output', async () => {
const inputStream = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
} as GenerateContentResponse;
yield {
candidates: [{ content: { parts: [{ text: ' World' }] } }],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream.mockResolvedValue(inputStream);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Hello' },
{ type: GeminiEventType.Content, value: ' World' },
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive(mockConfig, 'Test input', 'prompt-id-1');
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
{
message: [{ text: 'Test input' }],
config: {
abortSignal: expect.any(AbortSignal),
tools: [{ functionDeclarations: [] }],
},
},
expect.any(String),
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Hello');
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(' World');
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
expect(mockShutdownTelemetry).toHaveBeenCalled();
});
it('should handle a single tool call and respond', async () => {
const functionCall: FunctionCall = {
id: 'fc1',
name: 'testTool',
args: { p: 'v' },
};
const toolResponsePart: Part = {
functionResponse: {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'testTool',
id: 'fc1',
response: { result: 'tool success' },
args: { arg1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-id-2',
},
};
const toolResponse: Part[] = [{ text: 'Tool response' }];
mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse });
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen-code/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fc1',
responseParts: [toolResponsePart],
resultDisplay: 'Tool success display',
error: undefined,
});
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
const secondCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Final answer' },
];
const stream1 = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
const stream2 = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Final answer' }] } }],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
await runNonInteractive(mockConfig, 'Use a tool', 'prompt-id-2');
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({ callId: 'fc1', name: 'testTool' }),
expect.objectContaining({ name: 'testTool' }),
mockToolRegistry,
expect.any(AbortSignal),
);
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: [toolResponsePart],
}),
expect.any(String),
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
2,
[{ text: 'Tool response' }],
expect.any(AbortSignal),
'prompt-id-2',
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
});
it('should handle error during tool execution', async () => {
const functionCall: FunctionCall = {
id: 'fcError',
name: 'errorTool',
args: {},
};
const errorResponsePart: Part = {
functionResponse: {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'errorTool',
id: 'fcError',
response: { error: 'Tool failed' },
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-3',
},
};
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen-code/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fcError',
responseParts: [errorResponsePart],
resultDisplay: 'Tool execution failed badly',
error: new Error('Tool failed'),
mockCoreExecuteToolCall.mockResolvedValue({
error: new Error('Tool execution failed badly'),
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
});
const stream1 = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
const stream2 = (async function* () {
yield {
candidates: [
{ content: { parts: [{ text: 'Could not complete request.' }] } },
],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([toolCallEvent]),
);
await runNonInteractive(mockConfig, 'Trigger tool error', 'prompt-id-3');
@@ -201,75 +175,48 @@ describe('runNonInteractive', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool errorTool: Tool execution failed badly',
);
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: [errorResponsePart],
}),
expect.any(String),
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(
'Could not complete request.',
);
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should exit with error if sendMessageStream throws initially', async () => {
const apiError = new Error('API connection failed');
mockChat.sendMessageStream.mockRejectedValue(apiError);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockGeminiClient.sendMessageStream.mockImplementation(() => {
throw apiError;
});
await runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[API Error: API connection failed]',
);
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should not exit if a tool is not found, and should send error back to model', async () => {
const functionCall: FunctionCall = {
id: 'fcNotFound',
name: 'nonexistentTool',
args: {},
};
const errorResponsePart: Part = {
functionResponse: {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'nonexistentTool',
id: 'fcNotFound',
response: { error: 'Tool "nonexistentTool" not found in registry.' },
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-5',
},
};
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen-code/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fcNotFound',
responseParts: [errorResponsePart],
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
mockCoreExecuteToolCall.mockResolvedValue({
error: new Error('Tool "nonexistentTool" not found in registry.'),
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
});
const finalResponse: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.Content,
value: "Sorry, I can't find that tool.",
},
];
const stream1 = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
const stream2 = (async function* () {
yield {
candidates: [
{
content: {
parts: [{ text: 'Unfortunately the tool does not exist.' }],
},
},
],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
await runNonInteractive(
mockConfig,
@@ -277,68 +224,22 @@ describe('runNonInteractive', () => {
'prompt-id-5',
);
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
);
expect(mockProcessExit).not.toHaveBeenCalled();
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: [errorResponsePart],
}),
expect.any(String),
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(
'Unfortunately the tool does not exist.',
expect(processExitSpy).not.toHaveBeenCalled();
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(processStdoutSpy).toHaveBeenCalledWith(
"Sorry, I can't find that tool.",
);
});
it('should exit when max session turns are exceeded', async () => {
const functionCall: FunctionCall = {
id: 'fcLoop',
name: 'loopTool',
args: {},
};
const toolResponsePart: Part = {
functionResponse: {
name: 'loopTool',
id: 'fcLoop',
response: { result: 'still looping' },
},
};
// Config with a max turn of 1
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(1);
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen-code/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fcLoop',
responseParts: [toolResponsePart],
resultDisplay: 'Still looping',
error: undefined,
});
const stream = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
mockChat.sendMessageStream.mockResolvedValue(stream);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await runNonInteractive(mockConfig, 'Trigger loop');
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1);
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
await runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6');
expect(consoleErrorSpy).toHaveBeenCalledWith(
`
Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.`,
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
);
expect(mockProcessExit).not.toHaveBeenCalled();
});
});

View File

@@ -11,64 +11,47 @@ import {
ToolRegistry,
shutdownTelemetry,
isTelemetrySdkInitialized,
GeminiEventType,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
import {
Content,
Part,
FunctionCall,
GenerateContentResponse,
} from '@google/genai';
import { Content, Part, FunctionCall } from '@google/genai';
import { parseAndFormatApiError } from './ui/utils/errorParsing.js';
function getResponseText(response: GenerateContentResponse): string | null {
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0];
if (
candidate.content &&
candidate.content.parts &&
candidate.content.parts.length > 0
) {
// We are running in headless mode so we don't need to return thoughts to STDOUT.
const thoughtPart = candidate.content.parts[0];
if (thoughtPart?.thought) {
return null;
}
return candidate.content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join('');
}
}
return null;
}
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
export async function runNonInteractive(
config: Config,
input: string,
prompt_id: string,
): Promise<void> {
await config.initialize();
// Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
// Exit gracefully if the pipe is closed.
process.exit(0);
}
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: config.getDebugMode(),
});
const geminiClient = config.getGeminiClient();
const toolRegistry: ToolRegistry = await config.getToolRegistry();
const chat = await geminiClient.getChat();
const abortController = new AbortController();
let currentMessages: Content[] = [{ role: 'user', parts: [{ text: input }] }];
let turnCount = 0;
try {
await config.initialize();
consolePatcher.patch();
// Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
// Exit gracefully if the pipe is closed.
process.exit(0);
}
});
const geminiClient = config.getGeminiClient();
const toolRegistry: ToolRegistry = await config.getToolRegistry();
const abortController = new AbortController();
let currentMessages: Content[] = [
{ role: 'user', parts: [{ text: input }] },
];
let turnCount = 0;
while (true) {
turnCount++;
if (
config.getMaxSessionTurns() > 0 &&
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
console.error(
@@ -78,30 +61,28 @@ export async function runNonInteractive(
}
const functionCalls: FunctionCall[] = [];
const responseStream = await chat.sendMessageStream(
{
message: currentMessages[0]?.parts || [], // Ensure parts are always provided
config: {
abortSignal: abortController.signal,
tools: [
{ functionDeclarations: toolRegistry.getFunctionDeclarations() },
],
},
},
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
);
for await (const resp of responseStream) {
for await (const event of responseStream) {
if (abortController.signal.aborted) {
console.error('Operation cancelled.');
return;
}
const textPart = getResponseText(resp);
if (textPart) {
process.stdout.write(textPart);
}
if (resp.functionCalls) {
functionCalls.push(...resp.functionCalls);
if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) {
const toolCallRequest = event.value;
const fc: FunctionCall = {
name: toolCallRequest.name,
args: toolCallRequest.args,
id: toolCallRequest.callId,
};
functionCalls.push(fc);
}
}
@@ -126,15 +107,11 @@ export async function runNonInteractive(
);
if (toolResponse.error) {
const isToolNotFound = toolResponse.error.message.includes(
'not found in registry',
);
console.error(
`Error executing tool ${fc.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
);
if (!isToolNotFound) {
if (toolResponse.errorType === ToolErrorType.UNHANDLED_EXCEPTION)
process.exit(1);
}
}
if (toolResponse.responseParts) {
@@ -165,6 +142,7 @@ export async function runNonInteractive(
);
process.exit(1);
} finally {
consolePatcher.cleanup();
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry();
}

View File

@@ -16,10 +16,12 @@ import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
@@ -29,6 +31,8 @@ import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { isGitHubRepository } from '../utils/gitUtils.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -55,19 +59,22 @@ export class BuiltinCommandLoader implements ICommandLoader {
copyCommand,
corgiCommand,
docsCommand,
directoryCommand,
editorCommand,
extensionsCommand,
helpCommand,
ideCommand(this.config),
initCommand,
mcpCommand,
memoryCommand,
privacyCommand,
mcpCommand,
quitCommand,
restoreCommand(this.config),
statsCommand,
themeCommand,
toolsCommand,
vimCommand,
...(isGitHubRepository() ? [setupGithubCommand] : []),
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);

View File

@@ -177,4 +177,176 @@ describe('CommandService', () => {
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
expect(loader2.loadCommands).toHaveBeenCalledWith(signal);
});
it('should rename extension commands when they conflict', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const userCommand = createMockCommand('sync', CommandKind.FILE);
const extensionCommand1 = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
description: '[firebase] Deploy to Firebase',
};
const extensionCommand2 = {
...createMockCommand('sync', CommandKind.FILE),
extensionName: 'git-helper',
description: '[git-helper] Sync with remote',
};
const mockLoader1 = new MockCommandLoader([builtinCommand]);
const mockLoader2 = new MockCommandLoader([
userCommand,
extensionCommand1,
extensionCommand2,
]);
const service = await CommandService.create(
[mockLoader1, mockLoader2],
new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(4);
// Built-in command keeps original name
const deployBuiltin = commands.find(
(cmd) => cmd.name === 'deploy' && !cmd.extensionName,
);
expect(deployBuiltin).toBeDefined();
expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN);
// Extension command conflicting with built-in gets renamed
const deployExtension = commands.find(
(cmd) => cmd.name === 'firebase.deploy',
);
expect(deployExtension).toBeDefined();
expect(deployExtension?.extensionName).toBe('firebase');
// User command keeps original name
const syncUser = commands.find(
(cmd) => cmd.name === 'sync' && !cmd.extensionName,
);
expect(syncUser).toBeDefined();
expect(syncUser?.kind).toBe(CommandKind.FILE);
// Extension command conflicting with user command gets renamed
const syncExtension = commands.find(
(cmd) => cmd.name === 'git-helper.sync',
);
expect(syncExtension).toBeDefined();
expect(syncExtension?.extensionName).toBe('git-helper');
});
it('should handle user/project command override correctly', async () => {
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
const userCommand = createMockCommand('help', CommandKind.FILE);
const projectCommand = createMockCommand('deploy', CommandKind.FILE);
const userDeployCommand = createMockCommand('deploy', CommandKind.FILE);
const mockLoader1 = new MockCommandLoader([builtinCommand]);
const mockLoader2 = new MockCommandLoader([
userCommand,
userDeployCommand,
projectCommand,
]);
const service = await CommandService.create(
[mockLoader1, mockLoader2],
new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(2);
// User command overrides built-in
const helpCommand = commands.find((cmd) => cmd.name === 'help');
expect(helpCommand).toBeDefined();
expect(helpCommand?.kind).toBe(CommandKind.FILE);
// Project command overrides user command (last wins)
const deployCommand = commands.find((cmd) => cmd.name === 'deploy');
expect(deployCommand).toBeDefined();
expect(deployCommand?.kind).toBe(CommandKind.FILE);
});
it('should handle secondary conflicts when renaming extension commands', async () => {
// User has both /deploy and /gcp.deploy commands
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);
// Extension also has a deploy command that will conflict with user's /deploy
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'gcp',
description: '[gcp] Deploy to Google Cloud',
};
const mockLoader = new MockCommandLoader([
userCommand1,
userCommand2,
extensionCommand,
]);
const service = await CommandService.create(
[mockLoader],
new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(3);
// Original user command keeps its name
const deployUser = commands.find(
(cmd) => cmd.name === 'deploy' && !cmd.extensionName,
);
expect(deployUser).toBeDefined();
// User's dot notation command keeps its name
const gcpDeployUser = commands.find(
(cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName,
);
expect(gcpDeployUser).toBeDefined();
// Extension command gets renamed with suffix due to secondary conflict
const deployExtension = commands.find(
(cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp',
);
expect(deployExtension).toBeDefined();
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
});
it('should handle multiple secondary conflicts with incrementing suffixes', async () => {
// User has /deploy, /gcp.deploy, and /gcp.deploy1
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);
const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE);
// Extension has a deploy command
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'gcp',
description: '[gcp] Deploy to Google Cloud',
};
const mockLoader = new MockCommandLoader([
userCommand1,
userCommand2,
userCommand3,
extensionCommand,
]);
const service = await CommandService.create(
[mockLoader],
new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(4);
// Extension command gets renamed with suffix 2 due to multiple conflicts
const deployExtension = commands.find(
(cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp',
);
expect(deployExtension).toBeDefined();
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
});
});

View File

@@ -30,13 +30,17 @@ export class CommandService {
*
* This factory method orchestrates the entire command loading process. It
* runs all provided loaders in parallel, aggregates their results, handles
* name conflicts by letting the last-loaded command win, and then returns a
* name conflicts for extension commands by renaming them, and then returns a
* fully constructed `CommandService` instance.
*
* Conflict resolution:
* - Extension commands that conflict with existing commands are renamed to
* `extensionName.commandName`
* - Non-extension commands (built-in, user, project) override earlier commands
* with the same name based on loader order
*
* @param loaders An array of objects that conform to the `ICommandLoader`
* interface. The order of loaders is significant: if multiple loaders
* provide a command with the same name, the command from the loader that
* appears later in the array will take precedence.
* interface. Built-in commands should come first, followed by FileCommandLoader.
* @param signal An AbortSignal to cancel the loading process.
* @returns A promise that resolves to a new, fully initialized `CommandService` instance.
*/
@@ -57,12 +61,28 @@ export class CommandService {
}
}
// De-duplicate commands using a Map. The last one found with a given name wins.
// This creates a natural override system based on the order of the loaders
// passed to the constructor.
const commandMap = new Map<string, SlashCommand>();
for (const cmd of allCommands) {
commandMap.set(cmd.name, cmd);
let finalName = cmd.name;
// Extension commands get renamed if they conflict with existing commands
if (cmd.extensionName && commandMap.has(cmd.name)) {
let renamedName = `${cmd.extensionName}.${cmd.name}`;
let suffix = 1;
// Keep trying until we find a name that doesn't conflict
while (commandMap.has(renamedName)) {
renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`;
suffix++;
}
finalName = renamedName;
}
commandMap.set(finalName, {
...cmd,
name: finalName,
});
}
const finalCommands = Object.freeze(Array.from(commandMap.values()));

View File

@@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { FileCommandLoader } from './FileCommandLoader.js';
import * as path from 'node:path';
import {
Config,
getProjectCommandsDir,
getUserCommandsDir,
} from '@qwen-code/qwen-code-core';
import mock from 'mock-fs';
import { FileCommandLoader } from './FileCommandLoader.js';
import { assert, vi } from 'vitest';
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
import {
@@ -85,7 +86,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -176,7 +177,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(2);
@@ -194,9 +195,11 @@ describe('FileCommandLoader', () => {
},
},
});
const loader = new FileCommandLoader({
getProjectRoot: () => '/path/to/project',
} as Config);
const mockConfig = {
getProjectRoot: vi.fn(() => '/path/to/project'),
getExtensions: vi.fn(() => []),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
expect(commands[0]!.name).toBe('gcp:pipelines:run');
@@ -212,7 +215,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -221,7 +224,7 @@ describe('FileCommandLoader', () => {
expect(command.name).toBe('git:commit');
});
it('overrides user commands with project commands', async () => {
it('returns both user and project commands in order', async () => {
const userCommandsDir = getUserCommandsDir();
const projectCommandsDir = getProjectCommandsDir(process.cwd());
mock({
@@ -233,16 +236,15 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader({
getProjectRoot: () => process.cwd(),
} as Config);
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => []),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
const command = commands[0];
expect(command).toBeDefined();
const result = await command.action?.(
expect(commands).toHaveLength(2);
const userResult = await commands[0].action?.(
createMockCommandContext({
invocation: {
raw: '/test',
@@ -252,10 +254,25 @@ describe('FileCommandLoader', () => {
}),
'',
);
if (result?.type === 'submit_prompt') {
expect(result.content).toBe('Project prompt');
if (userResult?.type === 'submit_prompt') {
expect(userResult.content).toBe('User prompt');
} else {
assert.fail('Incorrect action type');
assert.fail('Incorrect action type for user command');
}
const projectResult = await commands[1].action?.(
createMockCommandContext({
invocation: {
raw: '/test',
name: 'test',
args: '',
},
}),
'',
);
if (projectResult?.type === 'submit_prompt') {
expect(projectResult.content).toBe('Project prompt');
} else {
assert.fail('Incorrect action type for project command');
}
});
@@ -268,7 +285,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -284,7 +301,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -299,7 +316,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
const command = commands[0];
expect(command).toBeDefined();
@@ -308,7 +325,7 @@ describe('FileCommandLoader', () => {
it('handles file system errors gracefully', async () => {
mock({}); // Mock an empty file system
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(0);
});
@@ -321,7 +338,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
const command = commands[0];
expect(command).toBeDefined();
@@ -336,7 +353,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
const command = commands[0];
expect(command).toBeDefined();
@@ -351,7 +368,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -362,6 +379,298 @@ describe('FileCommandLoader', () => {
expect(command.name).toBe('legacy_command');
});
describe('Extension Command Loading', () => {
it('loads commands from active extensions', async () => {
const userCommandsDir = getUserCommandsDir();
const projectCommandsDir = getProjectCommandsDir(process.cwd());
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/test-ext',
);
mock({
[userCommandsDir]: {
'user.toml': 'prompt = "User command"',
},
[projectCommandsDir]: {
'project.toml': 'prompt = "Project command"',
},
[extensionDir]: {
'gemini-extension.json': JSON.stringify({
name: 'test-ext',
version: '1.0.0',
}),
commands: {
'ext.toml': 'prompt = "Extension command"',
},
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{
name: 'test-ext',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(3);
const commandNames = commands.map((cmd) => cmd.name);
expect(commandNames).toEqual(['user', 'project', 'ext']);
const extCommand = commands.find((cmd) => cmd.name === 'ext');
expect(extCommand?.extensionName).toBe('test-ext');
expect(extCommand?.description).toMatch(/^\[test-ext\]/);
});
it('extension commands have extensionName metadata for conflict resolution', async () => {
const userCommandsDir = getUserCommandsDir();
const projectCommandsDir = getProjectCommandsDir(process.cwd());
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/test-ext',
);
mock({
[extensionDir]: {
'gemini-extension.json': JSON.stringify({
name: 'test-ext',
version: '1.0.0',
}),
commands: {
'deploy.toml': 'prompt = "Extension deploy command"',
},
},
[userCommandsDir]: {
'deploy.toml': 'prompt = "User deploy command"',
},
[projectCommandsDir]: {
'deploy.toml': 'prompt = "Project deploy command"',
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{
name: 'test-ext',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
// Return all commands, even duplicates
expect(commands).toHaveLength(3);
expect(commands[0].name).toBe('deploy');
expect(commands[0].extensionName).toBeUndefined();
const result0 = await commands[0].action?.(
createMockCommandContext({
invocation: {
raw: '/deploy',
name: 'deploy',
args: '',
},
}),
'',
);
expect(result0?.type).toBe('submit_prompt');
if (result0?.type === 'submit_prompt') {
expect(result0.content).toBe('User deploy command');
}
expect(commands[1].name).toBe('deploy');
expect(commands[1].extensionName).toBeUndefined();
const result1 = await commands[1].action?.(
createMockCommandContext({
invocation: {
raw: '/deploy',
name: 'deploy',
args: '',
},
}),
'',
);
expect(result1?.type).toBe('submit_prompt');
if (result1?.type === 'submit_prompt') {
expect(result1.content).toBe('Project deploy command');
}
expect(commands[2].name).toBe('deploy');
expect(commands[2].extensionName).toBe('test-ext');
expect(commands[2].description).toMatch(/^\[test-ext\]/);
const result2 = await commands[2].action?.(
createMockCommandContext({
invocation: {
raw: '/deploy',
name: 'deploy',
args: '',
},
}),
'',
);
expect(result2?.type).toBe('submit_prompt');
if (result2?.type === 'submit_prompt') {
expect(result2.content).toBe('Extension deploy command');
}
});
it('only loads commands from active extensions', async () => {
const extensionDir1 = path.join(
process.cwd(),
'.gemini/extensions/active-ext',
);
const extensionDir2 = path.join(
process.cwd(),
'.gemini/extensions/inactive-ext',
);
mock({
[extensionDir1]: {
'gemini-extension.json': JSON.stringify({
name: 'active-ext',
version: '1.0.0',
}),
commands: {
'active.toml': 'prompt = "Active extension command"',
},
},
[extensionDir2]: {
'gemini-extension.json': JSON.stringify({
name: 'inactive-ext',
version: '1.0.0',
}),
commands: {
'inactive.toml': 'prompt = "Inactive extension command"',
},
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{
name: 'active-ext',
version: '1.0.0',
isActive: true,
path: extensionDir1,
},
{
name: 'inactive-ext',
version: '1.0.0',
isActive: false,
path: extensionDir2,
},
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('active');
expect(commands[0].extensionName).toBe('active-ext');
expect(commands[0].description).toMatch(/^\[active-ext\]/);
});
it('handles missing extension commands directory gracefully', async () => {
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/no-commands',
);
mock({
[extensionDir]: {
'gemini-extension.json': JSON.stringify({
name: 'no-commands',
version: '1.0.0',
}),
// No commands directory
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{
name: 'no-commands',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(0);
});
it('handles nested command structure in extensions', async () => {
const extensionDir = path.join(process.cwd(), '.gemini/extensions/a');
mock({
[extensionDir]: {
'gemini-extension.json': JSON.stringify({
name: 'a',
version: '1.0.0',
}),
commands: {
b: {
'c.toml': 'prompt = "Nested command from extension a"',
d: {
'e.toml': 'prompt = "Deeply nested command"',
},
},
'simple.toml': 'prompt = "Simple command"',
},
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{ name: 'a', version: '1.0.0', isActive: true, path: extensionDir },
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(3);
const commandNames = commands.map((cmd) => cmd.name).sort();
expect(commandNames).toEqual(['b:c', 'b:d:e', 'simple']);
const nestedCmd = commands.find((cmd) => cmd.name === 'b:c');
expect(nestedCmd?.extensionName).toBe('a');
expect(nestedCmd?.description).toMatch(/^\[a\]/);
expect(nestedCmd).toBeDefined();
const result = await nestedCmd!.action?.(
createMockCommandContext({
invocation: {
raw: '/b:c',
name: 'b:c',
args: '',
},
}),
'',
);
if (result?.type === 'submit_prompt') {
expect(result.content).toBe('Nested command from extension a');
} else {
assert.fail('Incorrect action type');
}
});
});
describe('Shorthand Argument Processor Integration', () => {
it('correctly processes a command with {{args}}', async () => {
const userCommandsDir = getUserCommandsDir();

View File

@@ -35,6 +35,11 @@ import {
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
interface CommandDirectory {
path: string;
extensionName?: string;
}
/**
* Defines the Zod schema for a command definition file. This serves as the
* single source of truth for both validation and type inference.
@@ -65,13 +70,18 @@ export class FileCommandLoader implements ICommandLoader {
}
/**
* Loads all commands, applying the precedence rule where project-level
* commands override user-level commands with the same name.
* Loads all commands from user, project, and extension directories.
* Returns commands in order: user → project → extensions (alphabetically).
*
* Order is important for conflict resolution in CommandService:
* - User/project commands (without extensionName) use "last wins" strategy
* - Extension commands (with extensionName) get renamed if conflicts exist
*
* @param signal An AbortSignal to cancel the loading process.
* @returns A promise that resolves to an array of loaded SlashCommands.
* @returns A promise that resolves to an array of all loaded SlashCommands.
*/
async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {
const commandMap = new Map<string, SlashCommand>();
const allCommands: SlashCommand[] = [];
const globOptions = {
nodir: true,
dot: true,
@@ -79,54 +89,85 @@ export class FileCommandLoader implements ICommandLoader {
follow: true,
};
try {
// User Commands
const userDir = getUserCommandsDir();
const userFiles = await glob('**/*.toml', {
...globOptions,
cwd: userDir,
});
const userCommandPromises = userFiles.map((file) =>
this.parseAndAdaptFile(path.join(userDir, file), userDir),
);
const userCommands = (await Promise.all(userCommandPromises)).filter(
(cmd): cmd is SlashCommand => cmd !== null,
);
for (const cmd of userCommands) {
commandMap.set(cmd.name, cmd);
}
// Load commands from each directory
const commandDirs = this.getCommandDirectories();
for (const dirInfo of commandDirs) {
try {
const files = await glob('**/*.toml', {
...globOptions,
cwd: dirInfo.path,
});
// Project Commands (these intentionally override user commands)
const projectDir = getProjectCommandsDir(this.projectRoot);
const projectFiles = await glob('**/*.toml', {
...globOptions,
cwd: projectDir,
});
const projectCommandPromises = projectFiles.map((file) =>
this.parseAndAdaptFile(path.join(projectDir, file), projectDir),
);
const projectCommands = (
await Promise.all(projectCommandPromises)
).filter((cmd): cmd is SlashCommand => cmd !== null);
for (const cmd of projectCommands) {
commandMap.set(cmd.name, cmd);
const commandPromises = files.map((file) =>
this.parseAndAdaptFile(
path.join(dirInfo.path, file),
dirInfo.path,
dirInfo.extensionName,
),
);
const commands = (await Promise.all(commandPromises)).filter(
(cmd): cmd is SlashCommand => cmd !== null,
);
// Add all commands without deduplication
allCommands.push(...commands);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error,
);
}
}
} catch (error) {
console.error(`[FileCommandLoader] Error during file search:`, error);
}
return Array.from(commandMap.values());
return allCommands;
}
/**
* Get all command directories in order for loading.
* User commands → Project commands → Extension commands
* This order ensures extension commands can detect all conflicts.
*/
private getCommandDirectories(): CommandDirectory[] {
const dirs: CommandDirectory[] = [];
// 1. User commands
dirs.push({ path: getUserCommandsDir() });
// 2. Project commands (override user commands)
dirs.push({ path: getProjectCommandsDir(this.projectRoot) });
// 3. Extension commands (processed last to detect all conflicts)
if (this.config) {
const activeExtensions = this.config
.getExtensions()
.filter((ext) => ext.isActive)
.sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading
const extensionCommandDirs = activeExtensions.map((ext) => ({
path: path.join(ext.path, 'commands'),
extensionName: ext.name,
}));
dirs.push(...extensionCommandDirs);
}
return dirs;
}
/**
* Parses a single .toml file and transforms it into a SlashCommand object.
* @param filePath The absolute path to the .toml file.
* @param baseDir The root command directory for name calculation.
* @param extensionName Optional extension name to prefix commands with.
* @returns A promise resolving to a SlashCommand, or null if the file is invalid.
*/
private async parseAndAdaptFile(
filePath: string,
baseDir: string,
extensionName?: string,
): Promise<SlashCommand | null> {
let fileContent: string;
try {
@@ -167,7 +208,7 @@ export class FileCommandLoader implements ICommandLoader {
0,
relativePathWithExt.length - 5, // length of '.toml'
);
const commandName = relativePath
const baseCommandName = relativePath
.split(path.sep)
// Sanitize each path segment to prevent ambiguity. Since ':' is our
// namespace separator, we replace any literal colons in filenames
@@ -175,11 +216,18 @@ export class FileCommandLoader implements ICommandLoader {
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
// Add extension name tag for extension commands
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
let description = validDef.description || defaultDescription;
if (extensionName) {
description = `[${extensionName}] ${description}`;
}
const processors: IPromptProcessor[] = [];
// Add the Shell Processor if needed.
if (validDef.prompt.includes(SHELL_INJECTION_TRIGGER)) {
processors.push(new ShellProcessor(commandName));
processors.push(new ShellProcessor(baseCommandName));
}
// The presence of '{{args}}' is the switch that determines the behavior.
@@ -190,18 +238,17 @@ export class FileCommandLoader implements ICommandLoader {
}
return {
name: commandName,
description:
validDef.description ||
`Custom command from ${path.basename(filePath)}`,
name: baseCommandName,
description,
kind: CommandKind.FILE,
extensionName,
action: async (
context: CommandContext,
_args: string,
): Promise<SlashCommandActionReturn> => {
if (!context.invocation) {
console.error(
`[FileCommandLoader] Critical error: Command '${commandName}' was executed without invocation context.`,
`[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`,
);
return {
type: 'submit_prompt',

View File

@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/// <reference types="vitest/globals" />
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect } from 'vitest';
import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
// RegExp to detect invalid characters: backspace, and ANSI escape codes
// eslint-disable-next-line no-control-regex
const invalidCharsRegex = /[\b\x1b]/;
function toHaveOnlyValidCharacters(this: vi.Assertion, buffer: TextBuffer) {
const { isNot } = this;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
for (let i = 0; i < buffer.lines.length; i++) {
const line = buffer.lines[i];
if (line.includes('\n')) {
pass = false;
invalidLines.push({ line: i, content: line });
break; // Fail fast on newlines
}
if (invalidCharsRegex.test(line)) {
pass = false;
invalidLines.push({ line: i, content: line });
}
}
return {
pass,
message: () =>
`Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines
.map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */
.join('\n')}`,
actual: buffer.lines,
expected: 'Lines with no line breaks, backspaces, or escape codes.',
};
}
expect.extend({
toHaveOnlyValidCharacters,
});
// Extend Vitest's `expect` interface with the custom matcher's type definition.
declare module 'vitest' {
interface Assertion<T> {
toHaveOnlyValidCharacters(): T;
}
interface AsymmetricMatchersContaining {
toHaveOnlyValidCharacters(): void;
}
}

View File

@@ -53,8 +53,10 @@ export const createMockCommandContext = (
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
},
session: {
sessionShellAllowlist: new Set<string>(),
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,

View File

@@ -23,6 +23,10 @@ import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { StreamingState, ConsoleMessageItem } from './types.js';
import { Tips } from './components/Tips.js';
import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
import { EventEmitter } from 'events';
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
import * as auth from '../config/auth.js';
// Define a more complete mock server config based on actual Config
interface MockServerConfig {
@@ -144,25 +148,30 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getUserTier: vi.fn(),
})),
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
setFlashFallbackHandler: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
getUserTier: vi.fn().mockResolvedValue(undefined),
getIdeModeFeature: vi.fn(() => false),
getIdeMode: vi.fn(() => false),
getWorkspaceContext: vi.fn(() => ({
getDirectories: vi.fn(() => []),
})),
};
});
const ideContextMock = {
getOpenFilesContext: vi.fn(),
subscribeToOpenFiles: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function
getIdeContext: vi.fn(),
subscribeToIdeContext: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function
};
return {
...actualCore,
Config: ConfigClassMock,
MCPServerConfig: actualCore.MCPServerConfig,
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md']),
ideContext: ideContextMock,
isGitRepository: vi.fn(),
};
});
@@ -220,6 +229,21 @@ vi.mock('./components/Header.js', () => ({
Header: vi.fn(() => null),
}));
vi.mock('./utils/updateCheck.js', () => ({
checkForUpdates: vi.fn(),
}));
vi.mock('./config/auth.js', () => ({
validateAuthMethod: vi.fn(),
}));
const mockedCheckForUpdates = vi.mocked(checkForUpdates);
const { isGitRepository: mockedIsGitRepository } = vi.mocked(
await import('@qwen-code/qwen-code-core'),
);
vi.mock('node:child_process');
describe('App UI', () => {
let mockConfig: MockServerConfig;
let mockSettings: LoadedSettings;
@@ -277,7 +301,14 @@ describe('App UI', () => {
// Ensure a theme is set so the theme dialog does not appear.
mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue(undefined);
// Ensure getWorkspaceContext is available if not added by the constructor
if (!mockConfig.getWorkspaceContext) {
mockConfig.getWorkspaceContext = vi.fn(() => ({
getDirectories: vi.fn(() => ['/test/dir']),
}));
}
vi.mocked(ideContext.getIdeContext).mockReturnValue(undefined);
});
afterEach(() => {
@@ -288,11 +319,181 @@ describe('App UI', () => {
vi.clearAllMocks(); // Clear mocks after each test
});
describe('handleAutoUpdate', () => {
let spawnEmitter: EventEmitter;
beforeEach(async () => {
const { spawn } = await import('node:child_process');
spawnEmitter = new EventEmitter();
spawnEmitter.stdout = new EventEmitter();
spawnEmitter.stderr = new EventEmitter();
(spawn as vi.Mock).mockReturnValue(spawnEmitter);
});
afterEach(() => {
delete process.env.GEMINI_CLI_DISABLE_AUTOUPDATER;
});
it('should not start the update process when running from git', async () => {
mockedIsGitRepository.mockResolvedValue(true);
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Qwen Code update available!',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { spawn } = await import('node:child_process');
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await new Promise((resolve) => setTimeout(resolve, 10));
expect(spawn).not.toHaveBeenCalled();
});
it('should show a success message when update succeeds', async () => {
mockedIsGitRepository.mockResolvedValue(false);
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Update available',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
updateEventEmitter.emit('update-success', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Update successful! The new version will be used on your next run.',
);
});
it('should show an error message when update fails', async () => {
mockedIsGitRepository.mockResolvedValue(false);
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Update available',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
updateEventEmitter.emit('update-failed', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
});
it('should show an error message when spawn fails', async () => {
mockedIsGitRepository.mockResolvedValue(false);
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Update available',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// We are testing the App's reaction to an `update-failed` event,
// which is what should be emitted when a spawn error occurs elsewhere.
updateEventEmitter.emit('update-failed', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
});
it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => {
mockedIsGitRepository.mockResolvedValue(false);
process.env.GEMINI_CLI_DISABLE_AUTOUPDATER = 'true';
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Update available',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { spawn } = await import('node:child_process');
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await new Promise((resolve) => setTimeout(resolve, 10));
expect(spawn).not.toHaveBeenCalled();
});
});
it('should display active file when available', async () => {
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
activeFile: '/path/to/my-file.ts',
recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }],
selectedText: 'hello',
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
openFiles: [
{
path: '/path/to/my-file.ts',
isActive: true,
selectedText: 'hello',
timestamp: 0,
},
],
},
});
const { lastFrame, unmount } = render(
@@ -304,12 +505,14 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('1 recent file (ctrl+e to view)');
expect(lastFrame()).toContain('1 open file (ctrl+e to view)');
});
it('should not display active file when not available', async () => {
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
activeFile: '',
it('should not display any files when not available', async () => {
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
openFiles: [],
},
});
const { lastFrame, unmount } = render(
@@ -324,14 +527,57 @@ describe('App UI', () => {
expect(lastFrame()).not.toContain('Open File');
});
it('should display active file and other open files', async () => {
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
openFiles: [
{
path: '/path/to/my-file.ts',
isActive: true,
selectedText: 'hello',
timestamp: 0,
},
{
path: '/path/to/another-file.ts',
isActive: false,
timestamp: 1,
},
{
path: '/path/to/third-file.ts',
isActive: false,
timestamp: 2,
},
],
},
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('3 open files (ctrl+e to view)');
});
it('should display active file and other context', async () => {
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
activeFile: '/path/to/my-file.ts',
recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }],
selectedText: 'hello',
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
openFiles: [
{
path: '/path/to/my-file.ts',
isActive: true,
selectedText: 'hello',
timestamp: 0,
},
],
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
const { lastFrame, unmount } = render(
<App
@@ -343,13 +589,13 @@ describe('App UI', () => {
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain(
'Using: 1 recent file (ctrl+e to view) | 1 GEMINI.md file',
'Using: 1 open file (ctrl+e to view) | 1 QWEN.md file',
);
});
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
it('should display default "QWEN.md" in footer when contextFileName is not set and count is 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
@@ -363,15 +609,12 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve(); // Wait for any async updates
expect(lastFrame()).toContain('Using: 1 GEMINI.md file');
expect(lastFrame()).toContain('Using: 1 QWEN.md file');
});
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
it('should display default "QWEN.md" with plural when contextFileName is not set and count is > 1', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
@@ -384,7 +627,7 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Using: 2 GEMINI.md files');
expect(lastFrame()).toContain('Using: 2 QWEN.md files');
});
it('should display custom contextFileName in footer when set and count is 1', async () => {
@@ -481,12 +724,9 @@ describe('App UI', () => {
expect(lastFrame()).not.toContain('ANY_FILE.MD');
});
it('should display GEMINI.md and MCP server count when both are present', async () => {
it('should display QWEN.md and MCP server count when both are present', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
'GEMINI.md',
'GEMINI.md',
]);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md', 'QWEN.md']);
mockConfig.getMcpServers.mockReturnValue({
server1: {} as MCPServerConfig,
});
@@ -505,7 +745,7 @@ describe('App UI', () => {
expect(lastFrame()).toContain('1 MCP server');
});
it('should display only MCP server count when GEMINI.md count is 0', async () => {
it('should display only MCP server count when QWEN.md count is 0', async () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
mockConfig.getMcpServers.mockReturnValue({
@@ -764,4 +1004,50 @@ describe('App UI', () => {
expect(lastFrame()).toContain('5 errors');
});
});
describe('auth validation', () => {
it('should call validateAuthMethod when useExternalAuth is false', async () => {
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
mockSettings = createMockSettings({
workspace: {
selectedAuthType: 'USE_GEMINI' as AuthType,
useExternalAuth: false,
theme: 'Default',
},
});
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(validateAuthMethodSpy).toHaveBeenCalledWith('USE_GEMINI');
});
it('should NOT call validateAuthMethod when useExternalAuth is true', async () => {
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
mockSettings = createMockSettings({
workspace: {
selectedAuthType: 'USE_GEMINI' as AuthType,
useExternalAuth: true,
theme: 'Default',
},
});
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -22,6 +22,7 @@ import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './hooks/useAuthCommand.js';
import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
@@ -35,10 +36,10 @@ import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
import { AuthDialog } from './components/AuthDialog.js';
import { AuthInProgress } from './components/AuthInProgress.js';
import { QwenOAuthProgress } from './components/QwenOAuthProgress.js';
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
import { Colors } from './colors.js';
import { Help } from './components/Help.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import { LoadedSettings } from '../config/settings.js';
import { Tips } from './components/Tips.js';
@@ -60,7 +61,7 @@ import {
FlashFallbackEvent,
logFlashFallback,
AuthType,
type OpenFiles,
type IdeContext,
ideContext,
} from '@qwen-code/qwen-code-core';
import { validateAuthMethod } from '../config/auth.js';
@@ -83,11 +84,12 @@ import {
isGenericQuotaExceededError,
UserTierId,
} from '@qwen-code/qwen-code-core';
import { checkForUpdates } from './utils/updateCheck.js';
import { UpdateObject } from './utils/updateCheck.js';
import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js';
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from '../utils/events.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -110,15 +112,16 @@ export const AppWrapper = (props: AppProps) => (
const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const isFocused = useFocus();
useBracketedPaste();
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
const { stdout } = useStdout();
const nightly = version.includes('nightly');
const { history, addItem, clearItems, loadHistory } = useHistory();
useEffect(() => {
checkForUpdates().then(setUpdateMessage);
}, []);
const cleanup = setUpdateHandler(addItem, setUpdateInfo);
return cleanup;
}, [addItem]);
const { history, addItem, clearItems, loadHistory } = useHistory();
const {
consoleMessages,
handleNewMessage,
@@ -144,7 +147,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(0);
const [debugMessage, setDebugMessage] = useState<string>('');
const [showHelp, setShowHelp] = useState<boolean>(false);
const [themeError, setThemeError] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [editorError, setEditorError] = useState<string | null>(null);
@@ -169,13 +171,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false);
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [openFiles, setOpenFiles] = useState<OpenFiles | undefined>();
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
>();
const [isProcessing, setIsProcessing] = useState<boolean>(false);
useEffect(() => {
const unsubscribe = ideContext.subscribeToOpenFiles(setOpenFiles);
const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState);
// Set the initial value
setOpenFiles(ideContext.getOpenFilesContext());
setIdeContextState(ideContext.getIdeContext());
return unsubscribe;
}, []);
@@ -229,15 +233,29 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
cancelAuthentication,
} = useAuthCommand(settings, setAuthError, config);
const {
isQwenAuthenticating,
deviceAuth,
isQwenAuth,
cancelQwenAuth,
authStatus,
authMessage,
} = useQwenAuth(settings, isAuthenticating);
useEffect(() => {
if (settings.merged.selectedAuthType) {
if (settings.merged.selectedAuthType && !settings.merged.useExternalAuth) {
const error = validateAuthMethod(settings.merged.selectedAuthType);
if (error) {
setAuthError(error);
openAuthDialog();
}
}
}, [settings.merged.selectedAuthType, openAuthDialog, setAuthError]);
}, [
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
openAuthDialog,
setAuthError,
]);
// Sync user tier from config when authentication changes
useEffect(() => {
@@ -247,6 +265,27 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
}
}, [config, isAuthenticating]);
// Handle Qwen OAuth timeout
useEffect(() => {
if (isQwenAuth && authStatus === 'timeout') {
setAuthError(
authMessage ||
'Qwen OAuth authentication timed out. Please try again or select a different authentication method.',
);
cancelQwenAuth();
cancelAuthentication();
openAuthDialog();
}
}, [
isQwenAuth,
authStatus,
authMessage,
cancelQwenAuth,
cancelAuthentication,
openAuthDialog,
setAuthError,
]);
const {
isEditorDialogOpen,
openEditorDialog,
@@ -269,10 +308,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
try {
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.merged.loadMemoryFromIncludeDirectories
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
config.getFileService(),
settings.merged,
config.getExtensionContextFilePaths(),
settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
);
@@ -396,6 +439,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
// Switch model for future use but return false to stop current retry
config.setModel(fallbackModel);
config.setFallbackMode(true);
logFlashFallback(
config,
new FlashFallbackEvent(config.getContentGeneratorConfig().authType!),
@@ -462,7 +506,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
clearItems,
loadHistory,
refreshStatic,
setShowHelp,
setDebugMessage,
openThemeDialog,
openAuthDialog,
@@ -472,6 +515,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
openPrivacyNotice,
toggleVimEnabled,
setIsProcessing,
setGeminiMdFileCount,
);
const {
@@ -484,7 +528,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
config.getGeminiClient(),
history,
addItem,
setShowHelp,
config,
setDebugMessage,
handleSlashCommand,
@@ -494,6 +537,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
refreshStatic,
);
// Input handling
@@ -568,7 +612,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (Object.keys(mcpServers || {}).length > 0) {
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
}
} else if (key.ctrl && input === 'e' && ideContext) {
} else if (
key.ctrl &&
input === 'e' &&
config.getIdeMode() &&
ideContextState
) {
setShowIDEContextDetail((prev) => !prev);
} else if (key.ctrl && (input === 'c' || input === 'C')) {
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
@@ -587,7 +636,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (config) {
setGeminiMdFileCount(config.getGeminiMdFileCount());
}
}, [config]);
}, [config, config.getGeminiMdFileCount]);
const logger = useLogger();
const [userMessages, setUserMessages] = useState<string[]>([]);
@@ -754,9 +803,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
return (
<StreamingContext.Provider value={streamingState}>
<Box flexDirection="column" width="90%">
{/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */}
{updateMessage && <UpdateNotification message={updateMessage} />}
{/*
* The Static component is an Ink intrinsic in which there can only be 1 per application.
* Because of this restriction we're hacking it slightly by having a 'header' item here to
@@ -789,6 +835,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
item={h}
isPending={false}
config={config}
commands={slashCommands}
/>
)),
]}
@@ -816,9 +863,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
</OverflowProvider>
{showHelp && <Help commands={slashCommands} />}
<Box flexDirection="column" ref={mainControlsRef}>
{/* Move UpdateNotification to render update notification above input area */}
{updateInfo && <UpdateNotification message={updateInfo.message} />}
{startupWarnings.length > 0 && (
<Box
borderStyle="round"
@@ -858,13 +905,35 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
) : isAuthenticating ? (
<>
<AuthInProgress
onTimeout={() => {
setAuthError('Authentication timed out. Please try again.');
cancelAuthentication();
openAuthDialog();
}}
/>
{isQwenAuth && isQwenAuthenticating ? (
<QwenOAuthProgress
deviceAuth={deviceAuth || undefined}
authStatus={authStatus}
authMessage={authMessage}
onTimeout={() => {
setAuthError(
'Qwen OAuth authentication timed out. Please try again.',
);
cancelQwenAuth();
cancelAuthentication();
openAuthDialog();
}}
onCancel={() => {
setAuthError('Qwen OAuth authentication cancelled.');
cancelQwenAuth();
cancelAuthentication();
openAuthDialog();
}}
/>
) : (
<AuthInProgress
onTimeout={() => {
setAuthError('Authentication timed out. Please try again.');
cancelAuthentication();
openAuthDialog();
}}
/>
)}
{showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
@@ -943,7 +1012,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Text>
) : (
<ContextSummaryDisplay
openFiles={openFiles}
ideContext={ideContextState}
geminiMdFileCount={geminiMdFileCount}
contextFileNames={contextFileNames}
mcpServers={config.getMcpServers()}
@@ -963,7 +1032,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
</Box>
{showIDEContextDetail && (
<IDEContextDetailDisplay openFiles={openFiles} />
<IDEContextDetailDisplay
ideContext={ideContextState}
detectedIdeDisplay={config
.getIdeClient()
.getDetectedIdeDisplayName()}
/>
)}
{showErrorDetails && (
<OverflowProvider>

View File

@@ -62,6 +62,7 @@ describe('aboutCommand', () => {
});
it('should call addItem with all version info', async () => {
process.env.SANDBOX = '';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}

View File

@@ -40,14 +40,17 @@ describe('chatCommand', () => {
let mockGetChat: ReturnType<typeof vi.fn>;
let mockSaveCheckpoint: ReturnType<typeof vi.fn>;
let mockLoadCheckpoint: ReturnType<typeof vi.fn>;
let mockDeleteCheckpoint: ReturnType<typeof vi.fn>;
let mockGetHistory: ReturnType<typeof vi.fn>;
const getSubCommand = (name: 'list' | 'save' | 'resume'): SlashCommand => {
const getSubCommand = (
name: 'list' | 'save' | 'resume' | 'delete',
): SlashCommand => {
const subCommand = chatCommand.subCommands?.find(
(cmd) => cmd.name === name,
);
if (!subCommand) {
throw new Error(`/memory ${name} command not found.`);
throw new Error(`/chat ${name} command not found.`);
}
return subCommand;
};
@@ -59,6 +62,7 @@ describe('chatCommand', () => {
});
mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
mockLoadCheckpoint = vi.fn().mockResolvedValue([]);
mockDeleteCheckpoint = vi.fn().mockResolvedValue(true);
mockContext = createMockCommandContext({
services: {
@@ -72,6 +76,7 @@ describe('chatCommand', () => {
logger: {
saveCheckpoint: mockSaveCheckpoint,
loadCheckpoint: mockLoadCheckpoint,
deleteCheckpoint: mockDeleteCheckpoint,
initialize: vi.fn().mockResolvedValue(undefined),
},
},
@@ -85,7 +90,7 @@ describe('chatCommand', () => {
it('should have the correct main command definition', () => {
expect(chatCommand.name).toBe('chat');
expect(chatCommand.description).toBe('Manage conversation history.');
expect(chatCommand.subCommands).toHaveLength(3);
expect(chatCommand.subCommands).toHaveLength(4);
});
describe('list subcommand', () => {
@@ -297,4 +302,63 @@ describe('chatCommand', () => {
});
});
});
describe('delete subcommand', () => {
let deleteCommand: SlashCommand;
const tag = 'my-tag';
beforeEach(() => {
deleteCommand = getSubCommand('delete');
});
it('should return an error if tag is missing', async () => {
const result = await deleteCommand?.action?.(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
});
});
it('should return an error if checkpoint is not found', async () => {
mockDeleteCheckpoint.mockResolvedValue(false);
const result = await deleteCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with tag '${tag}'.`,
});
});
it('should delete the conversation', async () => {
const result = await deleteCommand?.action?.(mockContext, tag);
expect(mockDeleteCheckpoint).toHaveBeenCalledWith(tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${tag}' has been deleted.`,
});
});
describe('completion', () => {
it('should provide completion suggestions', async () => {
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation(
(async (_: string): Promise<Stats> =>
({
mtime: new Date(),
}) as Stats) as unknown as typeof fsPromises.stat,
);
const result = await deleteCommand?.completion?.(mockContext, 'a');
expect(result).toEqual(['alpha']);
});
});
});
});

View File

@@ -206,9 +206,49 @@ const resumeCommand: SlashCommand = {
},
};
const deleteCommand: SlashCommand = {
name: 'delete',
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
};
}
const { logger } = context.services;
await logger.initialize();
const deleted = await logger.deleteCheckpoint(tag);
if (deleted) {
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${tag}' has been deleted.`,
};
} else {
return {
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with tag '${tag}'.`,
};
}
},
completion: async (context, partialArg) => {
const chatDetails = await getSavedChatTags(context, true);
return chatDetails
.map((chat) => chat.name)
.filter((name) => name.startsWith(partialArg));
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history.',
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, saveCommand, resumeCommand],
subCommands: [listCommand, saveCommand, resumeCommand, deleteCommand],
};

View File

@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { directoryCommand, expandHomeDir } from './directoryCommand.js';
import { Config, WorkspaceContext } from '@qwen-code/qwen-code-core';
import { CommandContext } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
describe('directoryCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let mockWorkspaceContext: WorkspaceContext;
const addCommand = directoryCommand.subCommands?.find(
(c) => c.name === 'add',
);
const showCommand = directoryCommand.subCommands?.find(
(c) => c.name === 'show',
);
beforeEach(() => {
mockWorkspaceContext = {
addDirectory: vi.fn(),
getDirectories: vi
.fn()
.mockReturnValue([
path.normalize('/home/user/project1'),
path.normalize('/home/user/project2'),
]),
} as unknown as WorkspaceContext;
mockConfig = {
getWorkspaceContext: () => mockWorkspaceContext,
isRestrictiveSandbox: vi.fn().mockReturnValue(false),
getGeminiClient: vi.fn().mockReturnValue({
addDirectoryContext: vi.fn(),
}),
getWorkingDir: () => '/test/dir',
shouldLoadMemoryFromIncludeDirectories: () => false,
getDebugMode: () => false,
getFileService: () => ({}),
getExtensionContextFilePaths: () => [],
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
setUserMemory: vi.fn(),
setGeminiMdFileCount: vi.fn(),
} as unknown as Config;
mockContext = {
services: {
config: mockConfig,
settings: {
merged: {
memoryDiscoveryMaxDirs: 1000,
},
},
},
ui: {
addItem: vi.fn(),
},
} as unknown as CommandContext;
});
describe('show', () => {
it('should display the list of directories', () => {
if (!showCommand?.action) throw new Error('No action');
showCommand.action(mockContext, '');
expect(mockWorkspaceContext.getDirectories).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `Current workspace directories:\n- ${path.normalize(
'/home/user/project1',
)}\n- ${path.normalize('/home/user/project2')}`,
}),
expect.any(Number),
);
});
});
describe('add', () => {
it('should show an error if no path is provided', () => {
if (!addCommand?.action) throw new Error('No action');
addCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Please provide at least one path to add.',
}),
expect.any(Number),
);
});
it('should call addDirectory and show a success message for a single path', async () => {
const newPath = path.normalize('/home/user/new-project');
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, newPath);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `Successfully added directories:\n- ${newPath}`,
}),
expect.any(Number),
);
});
it('should call addDirectory for each path and show a success message for multiple paths', async () => {
const newPath1 = path.normalize('/home/user/new-project1');
const newPath2 = path.normalize('/home/user/new-project2');
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, `${newPath1},${newPath2}`);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`,
}),
expect.any(Number),
);
});
it('should show an error if addDirectory throws an exception', async () => {
const error = new Error('Directory does not exist');
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => {
throw error;
});
const newPath = path.normalize('/home/user/invalid-project');
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, newPath);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: `Error adding '${newPath}': ${error.message}`,
}),
expect.any(Number),
);
});
it('should handle a mix of successful and failed additions', async () => {
const validPath = path.normalize('/home/user/valid-project');
const invalidPath = path.normalize('/home/user/invalid-project');
const error = new Error('Directory does not exist');
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
(p: string) => {
if (p === invalidPath) {
throw error;
}
},
);
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, `${validPath},${invalidPath}`);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `Successfully added directories:\n- ${validPath}`,
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: `Error adding '${invalidPath}': ${error.message}`,
}),
expect.any(Number),
);
});
});
it('should correctly expand a Windows-style home directory path', () => {
const windowsPath = '%userprofile%\\Documents';
const expectedPath = path.win32.join(os.homedir(), 'Documents');
const result = expandHomeDir(windowsPath);
expect(path.win32.normalize(result)).toBe(
path.win32.normalize(expectedPath),
);
});
});

View File

@@ -0,0 +1,182 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand, CommandContext, CommandKind } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
export function expandHomeDir(p: string): string {
if (!p) {
return '';
}
let expandedPath = p;
if (p.toLowerCase().startsWith('%userprofile%')) {
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
} else if (p === '~' || p.startsWith('~/')) {
expandedPath = os.homedir() + p.substring(1);
}
return path.normalize(expandedPath);
}
export const directoryCommand: SlashCommand = {
name: 'directory',
altNames: ['dir'],
description: 'Manage workspace directories',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'add',
description:
'Add directories to the workspace. Use comma to separate multiple paths',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args: string) => {
const {
ui: { addItem },
services: { config },
} = context;
const [...rest] = args.split(' ');
if (!config) {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
},
Date.now(),
);
return;
}
const workspaceContext = config.getWorkspaceContext();
const pathsToAdd = rest
.join(' ')
.split(',')
.filter((p) => p);
if (pathsToAdd.length === 0) {
addItem(
{
type: MessageType.ERROR,
text: 'Please provide at least one path to add.',
},
Date.now(),
);
return;
}
if (config.isRestrictiveSandbox()) {
return {
type: 'message' as const,
messageType: 'error' as const,
content:
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
};
}
const added: string[] = [];
const errors: string[] = [];
for (const pathToAdd of pathsToAdd) {
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
added.push(pathToAdd.trim());
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
}
}
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
[
...config.getWorkspaceContext().getDirectories(),
...pathsToAdd,
],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
context.ui.setGeminiMdFileCount(fileCount);
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {
const gemini = config.getGeminiClient();
if (gemini) {
await gemini.addDirectoryContext();
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
}
if (errors.length > 0) {
addItem(
{
type: MessageType.ERROR,
text: errors.join('\n'),
},
Date.now(),
);
}
},
},
{
name: 'show',
description: 'Show all directories in the workspace',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
const {
ui: { addItem },
services: { config },
} = context;
if (!config) {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
},
Date.now(),
);
return;
}
const workspaceContext = config.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
const directoryList = directories.map((dir) => `- ${dir}`).join('\n');
addItem(
{
type: MessageType.INFO,
text: `Current workspace directories:\n${directoryList}`,
},
Date.now(),
);
},
},
],
};

View File

@@ -4,37 +4,49 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { helpCommand } from './helpCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { CommandKind } from './types.js';
describe('helpCommand', () => {
let mockContext: CommandContext;
const originalEnv = { ...process.env };
beforeEach(() => {
mockContext = {} as unknown as CommandContext;
mockContext = createMockCommandContext({
ui: {
addItem: vi.fn(),
},
} as unknown as CommandContext);
});
it("should return a dialog action and log a debug message for '/help'", () => {
const consoleDebugSpy = vi
.spyOn(console, 'debug')
.mockImplementation(() => {});
afterEach(() => {
process.env = { ...originalEnv };
vi.clearAllMocks();
});
it('should add a help message to the UI history', async () => {
if (!helpCommand.action) {
throw new Error('Help command has no action');
}
const result = helpCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'help',
});
expect(consoleDebugSpy).toHaveBeenCalledWith('Opening help UI ...');
await helpCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HELP,
timestamp: expect.any(Date),
}),
expect.any(Number),
);
});
it("should also be triggered by its alternative name '?'", () => {
// This test is more conceptual. The routing of altNames to the command
// is handled by the slash command processor, but we can assert the
// altNames is correctly defined on the command object itself.
expect(helpCommand.altNames).toContain('?');
it('should have the correct command properties', () => {
expect(helpCommand.name).toBe('help');
expect(helpCommand.kind).toBe(CommandKind.BUILT_IN);
expect(helpCommand.description).toBe('for help on Qwen Code');
});
});

View File

@@ -4,18 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind, SlashCommand } from './types.js';
import { MessageType, type HistoryItemHelp } from '../types.js';
export const helpCommand: SlashCommand = {
name: 'help',
altNames: ['?'],
description: 'for help on Qwen Code',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => {
console.debug('Opening help UI ...');
return {
type: 'dialog',
dialog: 'help',
description: 'for help on Qwen Code',
action: async (context) => {
const helpItem: Omit<HistoryItemHelp, 'id'> = {
type: MessageType.HELP,
timestamp: new Date(),
};
context.ui.addItem(helpItem, Date.now());
},
};

View File

@@ -15,24 +15,16 @@ import {
} from 'vitest';
import { ideCommand } from './ideCommand.js';
import { type CommandContext } from './types.js';
import { type Config } from '@qwen-code/qwen-code-core';
import * as child_process from 'child_process';
import { glob } from 'glob';
import { IDEConnectionStatus } from '@qwen-code/qwen-code-core/index.js';
import { type Config, DetectedIde } from '@qwen-code/qwen-code-core';
import * as core from '@qwen-code/qwen-code-core';
vi.mock('child_process');
vi.mock('glob');
function regexEscape(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
vi.mock('@qwen-code/qwen-code-core');
describe('ideCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let execSyncSpy: MockInstance;
let globSyncSpy: MockInstance;
let platformSpy: MockInstance;
beforeEach(() => {
@@ -40,15 +32,27 @@ describe('ideCommand', () => {
ui: {
addItem: vi.fn(),
},
services: {
settings: {
setValue: vi.fn(),
},
},
} as unknown as CommandContext;
mockConfig = {
getIdeModeFeature: vi.fn(),
getIdeMode: vi.fn(),
getIdeClient: vi.fn(),
getIdeClient: vi.fn(() => ({
reconnect: vi.fn(),
disconnect: vi.fn(),
getCurrentIde: vi.fn(),
getDetectedIdeDisplayName: vi.fn(),
getConnectionStatus: vi.fn(),
})),
setIdeModeAndSyncConnection: vi.fn(),
setIdeMode: vi.fn(),
} as unknown as Config;
execSyncSpy = vi.spyOn(child_process, 'execSync');
globSyncSpy = vi.spyOn(glob, 'sync');
platformSpy = vi.spyOn(process, 'platform', 'get');
});
@@ -56,51 +60,61 @@ describe('ideCommand', () => {
vi.restoreAllMocks();
});
it('should return null if ideMode is not enabled', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(false);
it('should return null if ideModeFeature is not enabled', () => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(false);
const command = ideCommand(mockConfig);
expect(command).toBeNull();
});
it('should return the ide command if ideMode is enabled', () => {
it('should return the ide command if ideModeFeature is enabled', () => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
} as ReturnType<Config['getIdeClient']>);
const command = ideCommand(mockConfig);
expect(command).not.toBeNull();
expect(command?.name).toBe('ide');
expect(command?.subCommands).toHaveLength(2);
expect(command?.subCommands?.[0].name).toBe('status');
expect(command?.subCommands?.[1].name).toBe('install');
expect(command?.subCommands).toHaveLength(3);
expect(command?.subCommands?.[0].name).toBe('disable');
expect(command?.subCommands?.[1].name).toBe('status');
expect(command?.subCommands?.[2].name).toBe('install');
});
describe('status subcommand', () => {
const mockGetConnectionStatus = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getConnectionStatus: mockGetConnectionStatus,
} as ReturnType<Config['getIdeClient']>);
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
} as unknown as ReturnType<Config['getIdeClient']>);
});
it('should show connected status', () => {
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Connected,
status: core.IDEConnectionStatus.Connected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: '🟢 Connected',
content: '🟢 Connected to VS Code',
});
});
it('should show connecting status', () => {
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Connecting,
status: core.IDEConnectionStatus.Connecting,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -110,10 +124,11 @@ describe('ideCommand', () => {
});
it('should show disconnected status', () => {
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Disconnected,
status: core.IDEConnectionStatus.Disconnected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -125,11 +140,12 @@ describe('ideCommand', () => {
it('should show disconnected status with details', () => {
const details = 'Something went wrong';
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Disconnected,
status: core.IDEConnectionStatus.Disconnected,
details,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -140,128 +156,77 @@ describe('ideCommand', () => {
});
describe('install subcommand', () => {
const mockInstall = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getConnectionStatus: vi.fn(),
getDetectedIdeDisplayName: () => 'VS Code',
} as unknown as ReturnType<Config['getIdeClient']>);
vi.mocked(core.getIdeInstaller).mockReturnValue({
install: mockInstall,
isInstalled: vi.fn(),
});
platformSpy.mockReturnValue('linux');
});
it('should show an error if VSCode is not installed', async () => {
execSyncSpy.mockImplementation(() => {
throw new Error('Command not found');
it('should install the extension', async () => {
mockInstall.mockResolvedValue({
success: true,
message: 'Successfully installed.',
});
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringMatching(/VS Code command-line tool .* not found/),
}),
expect.any(Number),
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
mockContext,
'',
);
});
it('should show an error if the VSIX file is not found', async () => {
execSyncSpy.mockReturnValue(''); // VSCode is installed
globSyncSpy.mockReturnValue([]); // No .vsix file found
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.',
}),
expect.any(Number),
);
});
it('should install the extension if found in the bundle directory', async () => {
const vsixPath = '/path/to/bundle/gemini.vsix';
execSyncSpy.mockReturnValue(''); // VSCode is installed
globSyncSpy.mockReturnValue([vsixPath]); // Found .vsix file
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(globSyncSpy).toHaveBeenCalledWith(
expect.stringContaining('.vsix'),
);
expect(execSyncSpy).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(
`code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`,
),
),
{ stdio: 'pipe' },
);
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
expect(mockInstall).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: `Installing VS Code companion extension...`,
text: `Installing IDE companion...`,
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
}),
expect.any(Number),
);
});
it('should install the extension if found in the dev directory', async () => {
const vsixPath = '/path/to/dev/gemini.vsix';
execSyncSpy.mockReturnValue(''); // VSCode is installed
// First glob call for bundle returns nothing, second for dev returns path.
globSyncSpy.mockReturnValueOnce([]).mockReturnValueOnce([vsixPath]);
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(globSyncSpy).toHaveBeenCalledTimes(2);
expect(execSyncSpy).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(
`code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`,
),
),
{ stdio: 'pipe' },
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
text: 'Successfully installed.',
}),
expect.any(Number),
);
});
it('should show an error if installation fails', async () => {
const vsixPath = '/path/to/bundle/gemini.vsix';
const errorMessage = 'Installation failed';
execSyncSpy
.mockReturnValueOnce('') // VSCode is installed check
.mockImplementation(() => {
// Installation command
const error: Error & { stderr?: Buffer } = new Error(
'Command failed',
);
error.stderr = Buffer.from(errorMessage);
throw error;
});
globSyncSpy.mockReturnValue([vsixPath]);
mockInstall.mockResolvedValue({
success: false,
message: 'Installation failed.',
});
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
mockContext,
'',
);
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
expect(mockInstall).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: `Installing IDE companion...`,
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: `Failed to install VS Code companion extension.`,
text: 'Installation failed.',
}),
expect.any(Number),
);

View File

@@ -4,154 +4,186 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { fileURLToPath } from 'url';
import { Config, IDEConnectionStatus } from '@qwen-code/qwen-code-core';
import {
Config,
DetectedIde,
IDEConnectionStatus,
IdeClient,
getIdeDisplayName,
getIdeInstaller,
} from '@qwen-code/qwen-code-core';
import {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import * as child_process from 'child_process';
import * as process from 'process';
import { glob } from 'glob';
import * as path from 'path';
import { SettingScope } from '../../config/settings.js';
const VSCODE_COMMAND = process.platform === 'win32' ? 'code.cmd' : 'code';
const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion';
function isVSCodeInstalled(): boolean {
try {
child_process.execSync(
process.platform === 'win32'
? `where.exe ${VSCODE_COMMAND}`
: `command -v ${VSCODE_COMMAND}`,
{ stdio: 'ignore' },
);
return true;
} catch {
return false;
function getIdeStatusMessage(ideClient: IdeClient): {
messageType: 'info' | 'error';
content: string;
} {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected:
return {
messageType: 'info',
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
};
case IDEConnectionStatus.Connecting:
return {
messageType: 'info',
content: `🟡 Connecting...`,
};
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
messageType: 'error',
content,
};
}
}
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config?.getIdeMode()) {
if (!config || !config.getIdeModeFeature()) {
return null;
}
const ideClient = config.getIdeClient();
const currentIDE = ideClient.getCurrentIde();
if (!currentIDE || !ideClient.getDetectedIdeDisplayName()) {
return {
name: 'ide',
description: 'manage IDE integration',
kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn =>
({
type: 'message',
messageType: 'error',
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values(
DetectedIde,
)
.map((ide) => getIdeDisplayName(ide))
.join(', ')}`,
}) as const,
};
}
return {
const ideSlashCommand: SlashCommand = {
name: 'ide',
description: 'manage IDE integration',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'status',
description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN,
action: (_context: CommandContext): SlashCommandActionReturn => {
const connection = config.getIdeClient()?.getConnectionStatus();
switch (connection?.status) {
case IDEConnectionStatus.Connected:
return {
type: 'message',
messageType: 'info',
content: `🟢 Connected`,
} as const;
case IDEConnectionStatus.Connecting:
return {
type: 'message',
messageType: 'info',
content: `🟡 Connecting...`,
} as const;
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
type: 'message',
messageType: 'error',
content,
} as const;
}
}
},
},
{
name: 'install',
description: 'install required VS Code companion extension',
kind: CommandKind.BUILT_IN,
action: async (context) => {
if (!isVSCodeInstalled()) {
context.ui.addItem(
{
type: 'error',
text: `VS Code command-line tool "${VSCODE_COMMAND}" not found in your PATH.`,
},
Date.now(),
);
return;
}
const bundleDir = path.dirname(fileURLToPath(import.meta.url));
// The VSIX file is copied to the bundle directory as part of the build.
let vsixFiles = glob.sync(path.join(bundleDir, '*.vsix'));
if (vsixFiles.length === 0) {
// If the VSIX file is not in the bundle, it might be a dev
// environment running with `npm start`. Look for it in the original
// package location, relative to the bundle dir.
const devPath = path.join(
bundleDir,
'..',
'..',
'..',
'..',
'..',
VSCODE_COMPANION_EXTENSION_FOLDER,
'*.vsix',
);
vsixFiles = glob.sync(devPath);
}
if (vsixFiles.length === 0) {
context.ui.addItem(
{
type: 'error',
text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.',
},
Date.now(),
);
return;
}
const vsixPath = vsixFiles[0];
const command = `${VSCODE_COMMAND} --install-extension ${vsixPath} --force`;
context.ui.addItem(
{
type: 'info',
text: `Installing VS Code companion extension...`,
},
Date.now(),
);
try {
child_process.execSync(command, { stdio: 'pipe' });
context.ui.addItem(
{
type: 'info',
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
},
Date.now(),
);
} catch (_error) {
context.ui.addItem(
{
type: 'error',
text: `Failed to install VS Code companion extension.`,
},
Date.now(),
);
}
},
},
],
subCommands: [],
};
const statusCommand: SlashCommand = {
name: 'status',
description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn => {
const { messageType, content } = getIdeStatusMessage(ideClient);
return {
type: 'message',
messageType,
content,
} as const;
},
};
const installCommand: SlashCommand = {
name: 'install',
description: `install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`,
kind: CommandKind.BUILT_IN,
action: async (context) => {
const installer = getIdeInstaller(currentIDE);
if (!installer) {
context.ui.addItem(
{
type: 'error',
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the IDE companion manually from its marketplace.`,
},
Date.now(),
);
return;
}
context.ui.addItem(
{
type: 'info',
text: `Installing IDE companion...`,
},
Date.now(),
);
const result = await installer.install();
if (result.success) {
config.setIdeMode(true);
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
}
context.ui.addItem(
{
type: result.success ? 'info' : 'error',
text: result.message,
},
Date.now(),
);
},
};
const enableCommand: SlashCommand = {
name: 'enable',
description: 'enable IDE integration',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
await config.setIdeModeAndSyncConnection(true);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
type: messageType,
text: content,
},
Date.now(),
);
},
};
const disableCommand: SlashCommand = {
name: 'disable',
description: 'disable IDE integration',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
await config.setIdeModeAndSyncConnection(false);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
type: messageType,
text: content,
},
Date.now(),
);
},
};
const ideModeEnabled = config.getIdeMode();
if (ideModeEnabled) {
ideSlashCommand.subCommands = [
disableCommand,
statusCommand,
installCommand,
];
} else {
ideSlashCommand.subCommands = [
enableCommand,
statusCommand,
installCommand,
];
}
return ideSlashCommand;
};

View File

@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { initCommand } from './initCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { type CommandContext } from './types.js';
// Mock the 'fs' module with both named and default exports to avoid breaking default import sites
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const existsSync = vi.fn();
const writeFileSync = vi.fn();
const readFileSync = vi.fn();
return {
...actual,
existsSync,
writeFileSync,
readFileSync,
default: {
...(actual as unknown as Record<string, unknown>),
existsSync,
writeFileSync,
readFileSync,
},
} as unknown as typeof import('fs');
});
describe('initCommand', () => {
let mockContext: CommandContext;
const targetDir = '/test/dir';
const DEFAULT_CONTEXT_FILENAME = 'QWEN.md';
const geminiMdPath = path.join(targetDir, DEFAULT_CONTEXT_FILENAME);
beforeEach(() => {
// Create a fresh mock context for each test
mockContext = createMockCommandContext({
services: {
config: {
getTargetDir: () => targetDir,
},
},
});
});
afterEach(() => {
// Clear all mocks after each test
vi.clearAllMocks();
});
it(`should inform the user if ${DEFAULT_CONTEXT_FILENAME} already exists and is non-empty`, async () => {
// Arrange: Simulate that the file exists
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue('# Existing content');
// Act: Run the command's action
const result = await initCommand.action!(mockContext, '');
// Assert: Check for the correct informational message
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `A ${DEFAULT_CONTEXT_FILENAME} file already exists in this directory. No changes were made.`,
});
// Assert: Ensure no file was written
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it(`should create ${DEFAULT_CONTEXT_FILENAME} and submit a prompt if it does not exist`, async () => {
// Arrange: Simulate that the file does not exist
vi.mocked(fs.existsSync).mockReturnValue(false);
// Act: Run the command's action
const result = await initCommand.action!(mockContext, '');
// Assert: Check that writeFileSync was called correctly
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
// Assert: Check that an informational message was added to the UI
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: 'info',
text: `Empty ${DEFAULT_CONTEXT_FILENAME} created. Now analyzing the project to populate it.`,
},
expect.any(Number),
);
// Assert: Check that the correct prompt is submitted
expect(result.type).toBe('submit_prompt');
expect(result.content).toContain(
'You are Qwen Code, an interactive CLI agent',
);
});
it(`should proceed to initialize when ${DEFAULT_CONTEXT_FILENAME} exists but is empty`, async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue(' \n ');
const result = await initCommand.action!(mockContext, '');
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
expect(result.type).toBe('submit_prompt');
});
it('should return an error if config is not available', async () => {
// Arrange: Create a context without config
const noConfigContext = createMockCommandContext();
if (noConfigContext.services) {
noConfigContext.services.config = null;
}
// Act: Run the command's action
const result = await initCommand.action!(noConfigContext, '');
// Assert: Check for the correct error message
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
});
});
});

View File

@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
export const initCommand: SlashCommand = {
name: 'init',
description: 'Analyzes the project and creates a tailored QWEN.md file.',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
_args: string,
): Promise<SlashCommandActionReturn> => {
if (!context.services.config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const targetDir = context.services.config.getTargetDir();
const contextFileName = getCurrentGeminiMdFilename();
const contextFilePath = path.join(targetDir, contextFileName);
try {
if (fs.existsSync(contextFilePath)) {
// If file exists but is empty (or whitespace), continue to initialize; otherwise, bail out
try {
const existing = fs.readFileSync(contextFilePath, 'utf8');
if (existing && existing.trim().length > 0) {
return {
type: 'message',
messageType: 'info',
content: `A ${contextFileName} file already exists in this directory. No changes were made.`,
};
}
} catch {
// If we fail to read, conservatively proceed to (re)create the file
}
}
// Ensure an empty context file exists before prompting the model to populate it
try {
fs.writeFileSync(contextFilePath, '', 'utf8');
context.ui.addItem(
{
type: 'info',
text: `Empty ${contextFileName} created. Now analyzing the project to populate it.`,
},
Date.now(),
);
} catch (err) {
return {
type: 'message',
messageType: 'error',
content: `Failed to create ${contextFileName}: ${err instanceof Error ? err.message : String(err)}`,
};
}
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Unexpected error preparing ${contextFileName}: ${error instanceof Error ? error.message : String(error)}`,
};
}
return {
type: 'submit_prompt',
content: `
You are Qwen Code, an interactive CLI agent. Analyze the current directory and generate a comprehensive ${contextFileName} file to be used as instructional context for future interactions.
**Analysis Process:**
1. **Initial Exploration:**
* Start by listing the files and directories to get a high-level overview of the structure.
* Read the README file (e.g., \`README.md\`, \`README.txt\`) if it exists. This is often the best place to start.
2. **Iterative Deep Dive (up to 10 files):**
* Based on your initial findings, select a few files that seem most important (e.g., configuration files, main source files, documentation).
* Read them. As you learn more, refine your understanding and decide which files to read next. You don't need to decide all 10 files at once. Let your discoveries guide your exploration.
3. **Identify Project Type:**
* **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project.
* **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else.
**${contextFileName} Content Generation:**
**For a Code Project:**
* **Project Overview:** Write a clear and concise summary of the project's purpose, main technologies, and architecture.
* **Building and Running:** Document the key commands for building, running, and testing the project. Infer these from the files you've read (e.g., \`scripts\` in \`package.json\`, \`Makefile\`, etc.). If you can't find explicit commands, provide a placeholder with a TODO.
* **Development Conventions:** Describe any coding styles, testing practices, or contribution guidelines you can infer from the codebase.
**For a Non-Code Project:**
* **Directory Overview:** Describe the purpose and contents of the directory. What is it for? What kind of information does it hold?
* **Key Files:** List the most important files and briefly explain what they contain.
* **Usage:** Explain how the contents of this directory are intended to be used.
**Final Output:**
Write the complete content to the \`${contextFileName}\` file. The output must be well-formatted Markdown.
`,
};
},
};

View File

@@ -14,15 +14,10 @@ import {
getMCPDiscoveryState,
DiscoveredMCPTool,
} from '@qwen-code/qwen-code-core';
import open from 'open';
import { MessageActionReturn } from './types.js';
import { Type, CallableTool } from '@google/genai';
// Mock external dependencies
vi.mock('open', () => ({
default: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
@@ -144,30 +139,15 @@ describe('mcpCommand', () => {
mockConfig.getMcpServers = vi.fn().mockReturnValue({});
});
it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => {
process.env.SANDBOX = 'sandbox';
it('should display a message with a URL when no MCP servers are configured', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'No MCP servers configured. Please open the following URL in your browser to view documentation:\nhttps://goo.gle/gemini-cli-docs-mcp',
'No MCP servers configured. Please view MCP documentation in your browser: https://goo.gle/gemini-cli-docs-mcp or use the cli /docs command',
});
expect(open).not.toHaveBeenCalled();
});
it('should display a message and open a URL when no MCP servers are configured outside a sandbox', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'No MCP servers configured. Opening documentation in your browser: https://goo.gle/gemini-cli-docs-mcp',
});
expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp');
});
});
@@ -232,9 +212,9 @@ describe('mcpCommand', () => {
);
expect(message).toContain('server2_tool1');
// Server 3 - Disconnected
// Server 3 - Disconnected but with cached tools, so shows as Ready
expect(message).toContain(
'🔴 \u001b[1mserver3\u001b[0m - Disconnected (1 tools cached)',
'🟢 \u001b[1mserver3\u001b[0m - Ready (1 tool)',
);
expect(message).toContain('server3_tool1');

View File

@@ -21,7 +21,6 @@ import {
mcpServerRequiresOAuth,
getErrorMessage,
} from '@qwen-code/qwen-code-core';
import open from 'open';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
@@ -60,21 +59,11 @@ const getMcpStatus = async (
if (serverNames.length === 0 && blockedMcpServers.length === 0) {
const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp';
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`,
};
} else {
// Open the URL in the browser
await open(docsUrl);
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`,
};
}
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Please view MCP documentation in your browser: ${docsUrl} or use the cli /docs command`,
};
}
// Check if any servers are still connecting
@@ -105,7 +94,15 @@ const getMcpStatus = async (
const promptRegistry = await config.getPromptRegistry();
const serverPrompts = promptRegistry.getPromptsByServer(serverName) || [];
const status = getMCPServerStatus(serverName);
const originalStatus = getMCPServerStatus(serverName);
const hasCachedItems = serverTools.length > 0 || serverPrompts.length > 0;
// If the server is "disconnected" but has prompts or cached tools, display it as Ready
// by using CONNECTED as the display status.
const status =
originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems
? MCPServerStatus.CONNECTED
: originalStatus;
// Add status indicator with descriptive text
let statusIndicator = '';
@@ -271,11 +268,14 @@ const getMcpStatus = async (
message += ' No tools or prompts available\n';
} else if (serverTools.length === 0) {
message += ' No tools available';
if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
if (originalStatus === MCPServerStatus.DISCONNECTED && needsAuthHint) {
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`;
}
message += '\n';
} else if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
} else if (
originalStatus === MCPServerStatus.DISCONNECTED &&
needsAuthHint
) {
// This case is for when serverTools.length > 0
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}\n`;
}

View File

@@ -161,6 +161,10 @@ describe('memoryCommand', () => {
getDebugMode: () => false,
getFileService: () => ({}) as FileDiscoveryService,
getExtensionContextFilePaths: () => [],
shouldLoadMemoryFromIncludeDirectories: () => false,
getWorkspaceContext: () => ({
getDirectories: () => [],
}),
getFileFilteringOptions: () => ({
ignore: [],
include: [],

View File

@@ -89,9 +89,13 @@ export const memoryCommand: SlashCommand = {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
config.shouldLoadMemoryFromIncludeDirectories()
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
);

View File

@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
import * as child_process from 'child_process';
import { setupGithubCommand } from './setupGithubCommand.js';
import { CommandContext, ToolActionReturn } from './types.js';
vi.mock('child_process');
describe('setupGithubCommand', () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns a tool action to download github workflows and handles paths', () => {
const fakeRepoRoot = '/github.com/fake/repo/root';
vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot);
const result = setupGithubCommand.action?.(
{} as CommandContext,
'',
) as ToolActionReturn;
expect(result.type).toBe('tool');
expect(result.toolName).toBe('run_shell_command');
expect(child_process.execSync).toHaveBeenCalledWith(
'git rev-parse --show-toplevel',
{
encoding: 'utf-8',
},
);
expect(child_process.execSync).toHaveBeenCalledWith('git remote -v', {
encoding: 'utf-8',
});
const { command } = result.toolArgs;
const expectedSubstrings = [
`mkdir -p "${fakeRepoRoot}/.github/workflows"`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-cli.yml"`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`,
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/v0/examples/workflows/',
];
for (const substring of expectedSubstrings) {
expect(command).toContain(substring);
}
});
it('throws an error if git root cannot be determined', () => {
vi.mocked(child_process.execSync).mockReturnValue('');
expect(() => {
setupGithubCommand.action?.({} as CommandContext, '');
}).toThrow('Unable to determine the Git root directory.');
});
});

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'path';
import { execSync } from 'child_process';
import { isGitHubRepository } from '../../utils/gitUtils.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
export const setupGithubCommand: SlashCommand = {
name: 'setup-github',
description: 'Set up GitHub Actions',
kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn => {
const gitRootRepo = execSync('git rev-parse --show-toplevel', {
encoding: 'utf-8',
}).trim();
if (!isGitHubRepository()) {
throw new Error('Unable to determine the Git root directory.');
}
const version = 'v0';
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`;
const workflows = [
'gemini-cli/gemini-cli.yml',
'issue-triage/gemini-issue-automated-triage.yml',
'issue-triage/gemini-issue-scheduled-triage.yml',
'pr-review/gemini-pr-review.yml',
];
const command = [
'set -e',
`mkdir -p "${gitRootRepo}/.github/workflows"`,
...workflows.map((workflow) => {
const fileName = path.basename(workflow);
return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`;
}),
'echo "Workflows downloaded successfully."',
].join(' && ');
return {
type: 'tool',
toolName: 'run_shell_command',
toolArgs: {
description:
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
command,
},
};
},
};

View File

@@ -59,6 +59,7 @@ export interface CommandContext {
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
};
// Session-specific data
session: {
@@ -99,7 +100,7 @@ export interface MessageActionReturn {
*/
export interface OpenDialogActionReturn {
type: 'dialog';
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy';
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
}
/**
@@ -158,6 +159,9 @@ export interface SlashCommand {
kind: CommandKind;
// Optional metadata for extension commands
extensionName?: string;
// The action to run. Optional for parent commands that only group sub-commands.
action?: (
context: CommandContext,

View File

@@ -189,7 +189,7 @@ describe('AuthDialog', () => {
);
// This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 1. OpenAI');
expect(lastFrame()).toContain('● 2. OpenAI');
});
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
@@ -217,8 +217,8 @@ describe('AuthDialog', () => {
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Default is OpenAI (only option available)
expect(lastFrame()).toContain('● 1. OpenAI');
// Default is Qwen OAuth (first option)
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
@@ -249,8 +249,8 @@ describe('AuthDialog', () => {
);
// Since the auth dialog doesn't show GEMINI_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default OpenAI option
expect(lastFrame()).toContain('● 1. OpenAI');
// it will just show the default Qwen OAuth option
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
});

View File

@@ -45,7 +45,10 @@ export function AuthDialog({
initialErrorMessage || null,
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
const items = [{ label: 'OpenAI', value: AuthType.USE_OPENAI }];
const items = [
{ label: 'Qwen OAuth', value: AuthType.QWEN_OAUTH },
{ label: 'OpenAI', value: AuthType.USE_OPENAI },
];
const initialAuthIndex = Math.max(
0,
@@ -65,6 +68,10 @@ export function AuthDialog({
return item.value === AuthType.USE_GEMINI;
}
if (process.env.QWEN_OAUTH_TOKEN) {
return item.value === AuthType.QWEN_OAUTH;
}
return item.value === AuthType.LOGIN_WITH_GOOGLE;
}),
);

View File

@@ -8,7 +8,7 @@ import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import {
type OpenFiles,
type IdeContext,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
@@ -18,7 +18,7 @@ interface ContextSummaryDisplayProps {
mcpServers?: Record<string, MCPServerConfig>;
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
showToolDescriptions?: boolean;
openFiles?: OpenFiles;
ideContext?: IdeContext;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
@@ -27,26 +27,28 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
mcpServers,
blockedMcpServers,
showToolDescriptions,
openFiles,
ideContext,
}) => {
const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0;
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
if (
geminiMdFileCount === 0 &&
mcpServerCount === 0 &&
blockedMcpServerCount === 0 &&
(openFiles?.recentOpenFiles?.length ?? 0) === 0
openFileCount === 0
) {
return <Text> </Text>; // Render an empty space to reserve height
}
const recentFilesText = (() => {
const count = openFiles?.recentOpenFiles?.length ?? 0;
if (count === 0) {
const openFilesText = (() => {
if (openFileCount === 0) {
return '';
}
return `${count} recent file${count > 1 ? 's' : ''} (ctrl+e to view)`;
return `${openFileCount} open file${
openFileCount > 1 ? 's' : ''
} (ctrl+e to view)`;
})();
const geminiMdText = (() => {
@@ -84,8 +86,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
let summaryText = 'Using: ';
const summaryParts = [];
if (recentFilesText) {
summaryParts.push(recentFilesText);
if (openFilesText) {
summaryParts.push(openFilesText);
}
if (geminiMdText) {
summaryParts.push(geminiMdText);

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Text, useInput } from 'ink';
import { useEffect, useRef, useState } from 'react';
import { Colors } from '../colors.js';
export const DebugProfiler = () => {
const numRenders = useRef(0);
const [showNumRenders, setShowNumRenders] = useState(false);
useEffect(() => {
numRenders.current++;
});
useInput((input, key) => {
if (key.ctrl && input === 'b') {
setShowNumRenders((prev) => !prev);
}
});
if (!showNumRenders) {
return null;
}
return (
<Text color={Colors.AccentYellow}>Renders: {numRenders.current} </Text>
);
};

View File

@@ -17,6 +17,8 @@ import process from 'node:process';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
interface FooterProps {
model: string;
targetDir: string;
@@ -52,6 +54,7 @@ export const Footer: React.FC<FooterProps> = ({
return (
<Box justifyContent="space-between" width="100%">
<Box>
{debugMode && <DebugProfiler />}
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={Colors.GradientColors}>

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